Newer
Older
openstack-caracal-dc-dc / scripts / site-tailscale.sh
#!/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