#!/usr/bin/env bash # Enroll using a locally prepared package and a short-lived root-only key file. set -euo pipefail export LC_ALL=C fail() { printf 'ERROR: %s\n' "$*" >&2; return 1; } validate_host() { local host=$1 label octet [[ -n "$host" && ${#host} -le 253 && "$host" != .* && "$host" != *. && "$host" != *..* ]] || { fail 'Invalid Headscale URL host'; return 1; } if [[ "$host" =~ ^[0-9]+(\.[0-9]+){3}$ ]]; then local octets=() IFS=. read -r -a octets <<< "$host" for octet in "${octets[@]}"; do [[ "$octet" =~ ^(0|[1-9][0-9]{0,2})$ ]] && ((10#$octet <= 255)) || { fail 'Invalid Headscale IPv4 host'; return 1; } done return 0 fi [[ "$host" =~ ^[A-Za-z0-9.-]+$ ]] || { fail 'Invalid Headscale URL host'; return 1; } local labels=() IFS=. read -r -a labels <<< "$host" for label in "${labels[@]}"; do [[ "$label" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$ ]] || { fail 'Invalid Headscale URL host label'; return 1; } done } validate_tags() { # Tailscale tags are not DNS labels: letters, digits and dashes are valid # after the first letter, with no DNS length or final-character limit. [[ -z "$1" || "$1" =~ ^tag:[A-Za-z][A-Za-z0-9-]*(,tag:[A-Za-z][A-Za-z0-9-]*)*$ ]] || { fail 'Invalid authorized tags'; return 1; } } validate_tailnet_status() { jq -e ' def tailip: split(".") | select(length == 4) | map(tonumber) | .[0] == 100 and .[1] >= 64 and .[1] <= 127 and all(.[]; . >= 0 and . <= 255); .BackendState == "Running" and (.Self.ID | type == "string" and length > 0) and (.Self.DNSName | type == "string" and length > 0) and ([.Self.TailscaleIPs[] | select(contains(":") | not)] | length == 1 and all(.[]; tailip)) ' "$1" >/dev/null || { fail 'Running Tailscale identity with one 100.64.0.0/10 IPv4 and MagicDNS name required'; return 1; } } write_tailnet_receipt() { local status=$1 url=$2 name=$3 destination=$4 pending validate_tailnet_status "$status" || return 1 [[ ! -L "$destination" && ! -L "$(dirname -- "$destination")" ]] || { fail 'Symlink in tailnet receipt path'; return 1; } pending=$(mktemp "${destination}.XXXXXXXX") jq --arg url "$url" --arg name "$name" '{version: 1, control_url: $url, hostname: $name, node_id: .Self.ID, node_public_key: .Self.PublicKey, ipv4: (.Self.TailscaleIPs[] | select(contains(":") | not)), dns_name: (.Self.DNSName | rtrimstr(".")), tags: ((.Self.Tags // []) | sort)}' "$status" > "$pending" if [[ -e "$destination" ]] && ! jq -e --slurpfile candidate "$pending" ' .control_url == $candidate[0].control_url and .hostname == $candidate[0].hostname and .node_id == $candidate[0].node_id and .ipv4 == $candidate[0].ipv4 and .dns_name == $candidate[0].dns_name and .tags == $candidate[0].tags' "$destination" >/dev/null; then rm -f -- "$pending"; fail 'Tailnet identity/address changed; reconcile the existing member, never silently rebind'; return 1 fi chmod 0600 "$pending" mv -fT -- "$pending" "$destination" } main() { local version='' package='' sha='' login='' login_port='' name='' auth='' tags='' action='' while (($#)); do case "$1" in --help|-h) echo 'Usage: bash tailscale.sh --check|--apply --version VERSION --package LOCAL_DEB --sha256 SHA --login-server HTTPS_URL --hostname NAME --tags tag:ENV-ROLE,... [--auth-key-file /run/platform-secrets/KEY]'; return 0;; --check|--apply) [[ -z "$action" ]] || return 1; action=$1; shift;; --version|--package|--sha256|--login-server|--hostname|--auth-key-file|--tags) (($# >= 2)) || return 1 case "$1" in --version) version=$2;; --package) package=$2;; --sha256) sha=$2;; --login-server) login=$2;; --hostname) name=$2;; --auth-key-file) auth=$2;; --tags) tags=$2;; esac; shift 2;; *) fail "Unknown argument: $1"; return 1;; esac done local word='[a-z][a-z0-9]{0,15}' [[ $EUID == 0 && ( "$action" == --check || "$action" == --apply ) && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && "$sha" =~ ^[a-f0-9]{64}$ && "$name" =~ ^(dev|test|stage|prod)-$word-$word(-$word)?-(0[1-9]|[1-9][0-9])$ ]] || { fail 'Explicit root/action/version/Headscale/hostname inputs required'; return 1; } if [[ "$login" =~ ^https://([^/:?#]+)(:([0-9]{1,5}))?/?$ ]]; then local login_host=${BASH_REMATCH[1]} login_port=${BASH_REMATCH[3]} validate_host "$login_host" if [[ -n "$login_port" ]]; then [[ "$login_port" =~ ^[0-9]{1,5}$ ]] && ((10#$login_port >= 1 && 10#$login_port <= 65535)) || { fail 'Invalid Headscale port'; return 1; } fi login=${login%/} else fail 'Explicit HTTPS Headscale URL required'; return 1 fi validate_tags "$tags" [[ -n "$tags" ]] || { fail 'Explicit expected Headscale authorization tags required'; return 1; } [[ -f "$package" && ! -L "$package" ]] || { fail 'Prepared Tailscale DEB required'; return 1; } printf '%s %s\n' "$sha" "$package" | sha256sum -c - >/dev/null [[ $(dpkg-deb -f "$package" Package) == tailscale && $(dpkg-deb -f "$package" Version) == "$version" && $(dpkg-deb -f "$package" Architecture) == amd64 ]] || { fail 'Package identity or architecture differs'; return 1; } if command -v tailscale >/dev/null; then [[ $(tailscale version | head -n 1) == "$version" ]] || { fail 'Existing version differs; use explicit maintenance'; return 1; }; fi if [[ -n "$auth" ]]; then [[ "$auth" == /run/platform-secrets/* && "$auth" != */../* && "$auth" != */./* && -f "$auth" && ! -L "$auth" && $(stat -c %u "$auth") == 0 && $(stat -c %a "$auth") == 600 && ! -L /run/platform-secrets ]] || { fail 'Use a root-owned 0600 temporary enrollment key file'; return 1; } fi [[ "$action" == --apply ]] || { echo 'Local inputs verified; network enrollment not checked.'; return 0; } umask 077 exec 9>>/run/lock/platform-tailscale.lock; flock -n 9 || { fail 'Enrollment is already running'; return 1; } local task cleanup status='' state task=$(mktemp -d) printf -v cleanup 'rm -rf -- %q' "$task" if [[ -n "$auth" ]]; then printf -v cleanup '%s; rm -f -- %q' "$cleanup" "$auth"; fi trap "$cleanup" EXIT if ! command -v tailscale >/dev/null; then apt-get -o DPkg::Lock::Timeout=60 install -y "$package"; fi systemctl enable --now tailscaled if ! tailscale status --json > "$task/status.json" 2> "$task/error"; then fail 'Cannot inspect the existing Tailscale identity; do not reset it'; return 1 fi state=$(jq -er .BackendState "$task/status.json") || { fail 'Tailscale returned an invalid status document'; return 1; } if [[ "$state" == Running ]]; then tailscale debug prefs > "$task/prefs.json" jq -e --arg url "$login" --arg name "$name" '(.ControlURL | if type == "string" then rtrimstr("/") else "" end) == $url and .Hostname == $name' "$task/prefs.json" >/dev/null || { fail 'Existing node belongs to another controller or name'; return 1; } echo 'Existing connected identity reused; enrollment key not consumed.' elif [[ "$state" == NeedsLogin ]]; then [[ -n "$auth" ]] || { fail 'Provide a short-lived single-use Headscale enrollment key'; return 1; } local up_args=(up --login-server="$login" --hostname="$name" --accept-routes=false --ssh=false --auth-key="file:$auth") # Headscale 0.29.6 takes pre-authenticated node tags from the key. # It rejects client RequestTags for every pre-auth-key registration. if ! tailscale "${up_args[@]}" > "$task/enroll-output" 2>&1; then fail 'Enrollment failed; inspect Headscale and the original node identity before retrying'; return 1 fi else fail 'Existing backend is not ready for first enrollment; inspect it without resetting identity'; return 1; fi tailscale status --json > "$task/status.json" || { fail 'Tailscale enrollment status is unavailable'; return 1; } validate_tailnet_status "$task/status.json" if [[ -n "$tags" ]]; then jq -e --arg expected "$tags" '($expected | split(",") | sort) == ((.Self.Tags // []) | sort)' "$task/status.json" >/dev/null || { fail 'Headscale authorization tags differ; inspect the pre-auth key and node without resetting identity'; return 1; } fi tailscale debug prefs > "$task/prefs.json" || { fail 'Tailscale preferences are unavailable'; return 1; } jq -e --arg url "$login" --arg name "$name" '(.ControlURL | if type == "string" then rtrimstr("/") else "" end) == $url and .Hostname == $name' "$task/prefs.json" >/dev/null || { fail 'Enrolled node belongs to another controller or name'; return 1; } jq -e '((.AdvertiseRoutes // []) | length == 0) and ((.ExitNodeID // "") == "") and ((.ExitNodeIP // "") == "")' "$task/prefs.json" >/dev/null || { fail 'Existing advertised routes or exit node need explicit network migration; identity preserved'; return 1; } tailscale set --accept-routes=false --ssh=false tailscale debug prefs > "$task/prefs.json" jq -e '.RouteAll == false and .RunSSH == false' "$task/prefs.json" >/dev/null || { fail 'Tailnet-only preferences were not applied'; return 1; } [[ ! -L /var/lib/platform-bootstrap ]] || { fail 'Symlink in receipt directory'; return 1; } install -d -m 0700 /var/lib/platform-bootstrap write_tailnet_receipt "$task/status.json" "$login" "$name" /var/lib/platform-bootstrap/tailnet.json cat /var/lib/platform-bootstrap/tailnet.json echo 'Tailnet identity recorded. Confirm this address with Headscale before generating cluster endpoints and TLS certificates; test firewall access separately.' rm -rf -- "$task" [[ -z "$auth" ]] || rm -f -- "$auth" trap - EXIT } if [[ ${BASH_SOURCE[0]:-} == "$0" || -z ${BASH_SOURCE[0]:-} ]]; then main "$@"; fi