#!/usr/bin/env bash
# scripts/site-tailscale.sh <check|install> <site> -- per-DC Tailscale subnet router.
#
# The STANDING per-DC operator-access path (D-129(iii) + its 2026-08-07 amendment, rulings
# a-d): a DEDICATED subnet-router VM in the utility band at .7 advertises THIS DC's metal-admin
# /22 to the tailnet, so operators reach the DC's metal-admin plane -- SSH consoles AND the
# routed dashboards (Horizon on the metal-admin VIP) -- over Tailscale. Star topology
# (operator->DC only; no DC-to-DC), single router (HA pinned), SNAT ON (default). Control
# plane is self-hosted Headscale at tailscale.baldurkeep.com.
#
# RUNS ON THE .7 SUBNET-ROUTER VM (10.12.8.7 dc0 / 10.12.68.7 dc1), not on vcloud/voffice1.
# Invoke from a repo host over ssh, piping the script (no repo clone on the .7 VM needed):
# check: ssh -J voffice1,<rack> <user>@10.12.8.7 'sudo bash -s' -- check vr1-dc0 < scripts/site-tailscale.sh
# install: same with 'install vr1-dc0'; the tagged pre-auth key is passed via $TS_AUTHKEY_FILE
# (a path on the .7 VM) or $TS_AUTHKEY -- NEVER on the command line, never printed.
#
# HEADSCALE-SIDE PREREQUISITES (control-plane work, NOT this script -- done on
# tailscale.baldurkeep.com; deferred until control-plane access, D-129(iii) amdt note 4):
# - a TAGGED pre-auth key for tag:subnet-router (tag identity removes user-auth + key expiry);
# - the autoApprovers policy for tag:subnet-router + each DC metal-admin route, written BEFORE
# the router first advertises (Headscale does NOT approve retroactively);
# - the STAR ACL: tag:operators -> each DC's metal-admin CIDR; router<->router DENY (an
# unpoliced Headscale is allow-all, so this ACL IS the star boundary).
# This script is the NODE-SIDE half; it fails closed if the key/policy are absent.
#
# EXIT: 0 ok | 1 check failed | 2 bad args/unknown site | 4 install failed.
# Harness: tests/site-tailscale/run-tests.sh (offline; fakes tailscale). ASCII + LF only.
set -uo pipefail
MODE="${1:-}"; SITE="${2:-}"
case "$MODE" in check|install) ;; *)
echo "usage: site-tailscale.sh <check|install> <site>" >&2; exit 2 ;; esac
# ---------------------------------------------------------------------------
# Constants (measured / ruled, one place).
# ---------------------------------------------------------------------------
# Control plane, MEASURED 2026-08-07 from office1-tailscale's ControlURL.
LOGIN_SERVER="${TS_LOGIN_SERVER:-https://tailscale.baldurkeep.com:443}"
# Tag identity (D-129(iii) amdt note 1). The exact tag string is finalised WITH the Headscale
# autoApprovers/ACL policy (deferred); env-overridable so the policy and this stay in one value.
TAG="${TS_TAG:-tag:subnet-router}"
# SNAT: ruling (d) = ON = tailscale's default, so NO --snat-subnet-routes flag is passed.
# ---------------------------------------------------------------------------
# Site table -- the metal-admin /22 THIS DC advertises. MEASURED (hard rule 2); every row
# cites its source. Non-overlapping per DC permanently (D-129(iii) amdt note 3 -- Headscale
# has no 4via6). The harness rejects a row without a MEASURED tag.
# ---------------------------------------------------------------------------
case "$SITE" in
vr1-dc0) METAL_ADMIN_CIDR="10.12.8.0/22" ;; # MEASURED: lib-net.sh vr1-dc0 metal-admin; dashboard VIP 10.12.8.58
vr1-dc1) METAL_ADMIN_CIDR="10.12.68.0/22" ;; # MEASURED: lib-net.sh vr1-dc1 metal-admin (D-124 dc1 addressing)
*) echo "site-tailscale.sh: unknown site '$SITE' (expected vr1-dc0|vr1-dc1)" >&2; exit 2 ;;
esac
TS="${TAILSCALE:-tailscale}" # overridable so the harness injects a fake
say(){ printf '%s\n' "$*"; }
# jget <key> [<key>...] -- navigate nested keys of `tailscale status --json`; no eval (a
# bracket-key path in an eval string collides its own quotes). Prints Python repr (lists incl.).
jget(){ "$TS" status --json 2>/dev/null | "${PY:-python3}" -c '
import json,sys
try: d=json.load(sys.stdin)
except Exception: sys.exit(0)
for k in sys.argv[1:]:
d = d.get(k) if isinstance(d,dict) else None
if d is None: break
print("" if d is None else d)' "$@" 2>/dev/null; }
# derive the router's OWN metal-admin leg CIDR at runtime and cross-check the table (hard rule
# 3: prefer a runtime identity over a bare literal; a mismatch means wrong site or wrong VM).
own_leg_matches(){
local net="${METAL_ADMIN_CIDR%/*}" pfx="${METAL_ADMIN_CIDR#*/}"
# match on the /24 the .7 host sits in (10.12.8. or 10.12.68.), leg address ends in .7
local base="${net%.*}" # 10.12.8 / 10.12.68 (net is x.y.z.0)
ip -o -4 addr show 2>/dev/null | grep -qE "inet ${base%.*}\.[0-9]+\.7/|inet ${base}\.7/"
}
check(){
local rc=0 st routes tags
st="$(jget BackendState)"
[ "$st" = "Running" ] || { say "FAIL: tailscale BackendState='$st' (not Running)"; rc=1; }
routes="$(jget Self PrimaryRoutes | tr -d "[]' " )"
case ",$routes," in *,"$METAL_ADMIN_CIDR",*) say "OK: advertising+approved $METAL_ADMIN_CIDR";;
*) say "FAIL: $METAL_ADMIN_CIDR not in approved PrimaryRoutes ('$routes') -- route unadvertised or not approved by Headscale"; rc=1;; esac
tags="$(jget Self Tags | tr -d "[]' ")"
case ",$tags," in *,"$TAG",*) say "OK: tagged identity $TAG";;
*) say "FAIL: node not tagged $TAG (tags='$tags') -- untagged carries a key-expiry clock (note 1)"; rc=1;; esac
if own_leg_matches; then say "OK: router holds a .7 metal-admin leg in $METAL_ADMIN_CIDR"
else say "FAIL: no .7 metal-admin leg in $METAL_ADMIN_CIDR on this host -- wrong VM or unconfigured"; rc=1; fi
return $rc
}
install(){
command -v "$TS" >/dev/null 2>&1 || { say "install: tailscale not present -- install the package first"; return 4; }
local key="${TS_AUTHKEY:-}"
[ -z "$key" ] && [ -n "${TS_AUTHKEY_FILE:-}" ] && [ -s "$TS_AUTHKEY_FILE" ] && key="$(cat "$TS_AUTHKEY_FILE")"
[ -n "$key" ] || { say "install: no tagged pre-auth key (\$TS_AUTHKEY / \$TS_AUTHKEY_FILE) -- mint it on Headscale first (deferred)"; return 4; }
# SNAT default (on) per ruling (d): no --snat-subnet-routes flag. Idempotent: tailscale up is
# a state assertion. --advertise-tags requires the key to be authorised for the tag.
"$TS" up --login-server="$LOGIN_SERVER" --authkey="$key" \
--advertise-routes="$METAL_ADMIN_CIDR" --advertise-tags="$TAG" \
--hostname="${SITE}-tailscale" >/dev/null 2>&1 \
|| { say "install: 'tailscale up' failed (key rejected, tag not authorised, or control-plane unreachable)"; return 4; }
say "install: tailscale up issued for $SITE advertising $METAL_ADMIN_CIDR as $TAG"
say "NOTE: Headscale must APPROVE the route (autoApprovers, or manual) -- run 'check' to confirm."
}
case "$MODE" in
check) check || exit 1 ;;
install) install || exit 4 ; check || { say "install ran but check not yet green (route approval pending on Headscale)"; exit 1; } ;;
esac