diff --git a/scripts/site-tailscale.sh b/scripts/site-tailscale.sh new file mode 100755 index 0000000..1c9a2db --- /dev/null +++ b/scripts/site-tailscale.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# scripts/site-tailscale.sh -- 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, @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 " >&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 [...] -- 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 diff --git a/tests/HARNESS-MANIFEST b/tests/HARNESS-MANIFEST index 5a5dd10..c1b925c 100644 --- a/tests/HARNESS-MANIFEST +++ b/tests/HARNESS-MANIFEST @@ -91,6 +91,7 @@ site-forward site-headend-install site-ssh-config +site-tailscale tenant-acceptance tenant-assert tenant-offboard diff --git a/tests/site-tailscale/run-tests.sh b/tests/site-tailscale/run-tests.sh new file mode 100755 index 0000000..f4a7822 --- /dev/null +++ b/tests/site-tailscale/run-tests.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# tests/site-tailscale/run-tests.sh -- offline harness for scripts/site-tailscale.sh. +# Fakes `tailscale` (status --json / up / version) and `ip`; real python3 for jget. NO network, +# mutates nothing outside mktemp. Proves each exit path can FIRE (a new assertion is decoration +# until it has a failing-direction fixture): +# check-happy -> 0 (Running + route approved + tagged + .7 leg) +# check-notrun -> 1 (BackendState != Running) +# check-noroute -> 1 (metal-admin CIDR not in approved PrimaryRoutes) +# check-notag -> 1 (node not tagged) +# check-noleg -> 1 (no .7 metal-admin leg on the host) +# install-nokey -> 4 (no tagged pre-auth key) +# install-noTS -> 4 (tailscale binary absent) +# install-upfail -> 4 (tailscale up rejected) +# install-happy -> 0 (up ok + check green) +# badmode/badsite/noargs -> 2 +# site-table rows carry MEASURED tags (hard rule 2) +# dc1 site resolves its own CIDR (10.12.68.0/22), not dc0's +# Exit: 0 all pass | 1 any case failed. ASCII + LF. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +SUT="$REPO/scripts/site-tailscale.sh" +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +PASS=0; FAIL=0 +BIN="$TMP/bin"; mkdir -p "$BIN" + +# ---- fake tailscale -------------------------------------------------------- +cat > "$BIN/tailscale" <<'FB' +#!/usr/bin/env bash +mode="${TS_FAKE_MODE:-happy}"; cidr="${TS_FAKE_CIDR:-10.12.8.0/22}" +case "$1" in + version) echo "1.98.9"; exit 0;; + up) [ "$mode" = upfail ] && exit 1; exit 0;; + status) + # $2 == --json + st='"Running"'; routes="[\"$cidr\"]"; tags='["tag:subnet-router"]' + case "$mode" in + notrun) st='"Stopped"';; + noroute) routes='[]';; + notag) tags='[]';; + esac + printf '{"BackendState":%s,"Self":{"PrimaryRoutes":%s,"Tags":%s}}\n' "$st" "$routes" "$tags" + exit 0;; + *) exit 0;; +esac +FB +chmod +x "$BIN/tailscale" + +# ---- fake ip (own_leg_matches) -------------------------------------------- +cat > "$BIN/ip" <<'FB' +#!/usr/bin/env bash +# emit a .7 metal-admin leg unless TS_FAKE_MODE=noleg +[ "${TS_FAKE_MODE:-}" = noleg ] && { echo "1: lo inet 127.0.0.1/8 scope host lo"; exit 0; } +echo "2: enp1s0 inet ${TS_FAKE_LEG:-10.12.8.7}/22 scope global enp1s0" +exit 0 +FB +chmod +x "$BIN/ip" + +run(){ # + local name="$1" exp="$2" mode="$3"; shift 3 + local out code + out="$(PATH="$BIN:$PATH" TAILSCALE="$BIN/tailscale" TS_FAKE_MODE="$mode" \ + TS_AUTHKEY="${CASE_AUTHKEY:-}" TAILSCALE="${CASE_TS:-$BIN/tailscale}" \ + bash "$SUT" "$@" 2>&1)"; code=$? + if [ "$code" -eq "$exp" ]; then PASS=$((PASS+1)); echo "PASS: $name (exit $code)" + else FAIL=$((FAIL+1)); echo "FAIL: $name (got $code want $exp)"; echo "$out" | sed 's/^/ /'; fi +} + +echo "=== site-tailscale harness ===" +# check cases (dc0) +run check-happy 0 happy check vr1-dc0 +run check-notrun 1 notrun check vr1-dc0 +run check-noroute 1 noroute check vr1-dc0 +run check-notag 1 notag check vr1-dc0 +run check-noleg 1 noleg check vr1-dc0 +# install cases +CASE_AUTHKEY="tskey-fake" run install-happy 0 happy install vr1-dc0 + run install-nokey 4 happy install vr1-dc0 +CASE_AUTHKEY="tskey-fake" run install-upfail 4 upfail install vr1-dc0 +CASE_TS="$BIN/nope" CASE_AUTHKEY="tskey-fake" run install-noTS 4 happy install vr1-dc0 +# arg/site validation +run badmode 2 happy frobnicate vr1-dc0 +run badsite 2 happy check vr1-dc9 +run noargs 2 happy + +# dc1 resolves its OWN cidr (not dc0's) -- run check-happy with dc1 fixture +PASS_BEFORE=$PASS +out="$(PATH="$BIN:$PATH" TAILSCALE="$BIN/tailscale" TS_FAKE_MODE=happy TS_FAKE_CIDR='10.12.68.0/22' TS_FAKE_LEG='10.12.68.7' \ + bash "$SUT" check vr1-dc1 2>&1)"; code=$? +if [ "$code" -eq 0 ] && printf '%s' "$out" | grep -q '10.12.68.0/22'; then PASS=$((PASS+1)); echo "PASS: dc1 resolves 10.12.68.0/22 (exit 0)" +else FAIL=$((FAIL+1)); echo "FAIL: dc1 site (got $code)"; echo "$out" | sed 's/^/ /'; fi + +# site-table rows must carry MEASURED tags (hard rule 2) +for s in 'vr1-dc0.*MEASURED' 'vr1-dc1.*MEASURED'; do + if grep -qE "$s" "$SUT"; then PASS=$((PASS+1)); echo "PASS: site row tagged MEASURED ($s)" + else FAIL=$((FAIL+1)); echo "FAIL: site row missing MEASURED ($s)"; fi +done + +echo "=== $PASS pass / $FAIL fail ===" +[ "$FAIL" -eq 0 ]