#!/usr/bin/env bash
# scripts/site-tailscale.sh <prep|install|check> <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):
#   prep:     ssh -J voffice1,<rack> <user>@10.12.8.7 'sudo TS_DEB=/tmp/tailscale.deb bash -s' -- prep vr1-dc0 < scripts/site-tailscale.sh
#   install:  ssh ... 'sudo TS_AUTHKEY_FILE=/tmp/ts.key bash -s' -- install vr1-dc0 < scripts/site-tailscale.sh
#   check:    ssh ... 'sudo bash -s' -- check vr1-dc0 < scripts/site-tailscale.sh
# The 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.
#
# THREE VERBS, mapping onto the two-phase blocker:
#   prep    -- NODE-SIDE, runs any time: installs the tailscale .deb (from $TS_DEB, since the .7
#              has no external egress -- stage pool/tailscale_<ver>_amd64.deb from the rack, which
#              DOES have egress) and enables IP forwarding (the load-bearing subnet-router
#              property: 'tailscale up --advertise-routes' WARNS-and-succeeds with forwarding off,
#              so nothing reaches Horizon while every other check reads green). Idempotent.
#   install -- NODE-SIDE join: 'tailscale up' advertising this DC's metal-admin /22 + --accept-routes.
#   check   -- NODE-SIDE verify: Running + route approved + forwarding + (optional) tag + .7 leg.
#
# TAG / ACL POSTURE -- VR1 mirrors the office1 router, which is UNTAGGED (MEASURED 2026-08-07:
# office1-tailscale Tags=None, AdvertiseRoutes=[10.10.0.0/22], RouteAll=True). D-129(iii)'s TAGGED
# identity + autoApprovers + star ACL are DEFERRED to the bare-metal/Roosevelt install (operator
# 2026-08-07: "We can pin those for the bare metal install ... just follow the steps we used to
# bring office1 online" -- D-129(iii) amendment). So TS_TAG defaults EMPTY (untagged) and route
# approval is MANUAL on Headscale. Set TS_TAG=tag:subnet-router to restore the tagged design (and
# then the Headscale autoApprovers/star ACL become prerequisites again).
#
# EXIT: 0 ok | 1 check failed | 2 bad args/unknown site | 4 prep/install failed.
# Harness: tests/site-tailscale/run-tests.sh (offline; fakes tailscale/ip/sysctl/dpkg). ASCII + LF.
set -uo pipefail

MODE="${1:-}"; SITE="${2:-}"
case "$MODE" in prep|install|check) ;; *)
  echo "usage: site-tailscale.sh <prep|install|check> <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: DEFAULT EMPTY = untagged (VR1 office1-mirrored per the operator 2026-08-07
# deferral above). ${TS_TAG-} not :- so an explicit TS_TAG= is honoured as untagged. Set
# TS_TAG=tag:subnet-router to opt into the D-129(iii) tagged design (Roosevelt/bare-metal).
TAG="${TS_TAG-}"
# 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
SYSCTL="${SYSCTL:-sysctl}"                          # overridable so the harness fakes forwarding
SYSCTL_D="${TS_SYSCTL_D:-/etc/sysctl.d}"           # overridable so the harness writes to a tmp dir
CURL="${CURL:-curl}"                                # overridable so the harness fakes control reach
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; }

# fwd_ok -- both v4 and v6 forwarding on. Prints "v4=<> v6=<>" to stdout, returns 0/1.
fwd_ok(){
  local v4 v6
  v4="$("$SYSCTL" -n net.ipv4.ip_forward 2>/dev/null)"
  v6="$("$SYSCTL" -n net.ipv6.conf.all.forwarding 2>/dev/null)"
  printf 'v4=%s v6=%s' "$v4" "$v6"
  [ "$v4" = 1 ] && [ "$v6" = 1 ]
}

# 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/"
}

prep(){
  # 1. package present -- the .7 has NO external egress, so we do NOT apt from here. If tailscale
  #    is absent, install from a STAGED .deb ($TS_DEB, pulled on a host that has egress -- the
  #    rack). This mirrors office1's package (deb from pkgs.tailscale.com), not a snap.
  if ! command -v "$TS" >/dev/null 2>&1; then
    if [ -n "${TS_DEB:-}" ] && [ -s "${TS_DEB:-}" ]; then
      "${DPKG:-dpkg}" -i "$TS_DEB" >/dev/null 2>&1 \
        || "${APTGET:-apt-get}" -y -f install >/dev/null 2>&1 \
        || { say "prep: install of $TS_DEB failed (deps? -- needs iptables, iproute2)"; return 4; }
    else
      say "prep: tailscale absent and no \$TS_DEB staged -- stage pool/tailscale_<ver>_amd64.deb from the rack (it has egress) and pass TS_DEB=<path>"; return 4
    fi
  fi
  command -v "$TS" >/dev/null 2>&1 || { say "prep: tailscale still absent after install attempt"; return 4; }
  # 2. IP forwarding -- mirror office1 (/etc/sysctl.d/99-tailscale.conf, v4+v6). Idempotent write.
  local f="$SYSCTL_D/99-tailscale.conf"
  printf '%s\n' '# Managed by site-tailscale.sh -- subnet-router forwarding (mirrors office1).' \
                'net.ipv4.ip_forward = 1' 'net.ipv6.conf.all.forwarding = 1' > "$f" 2>/dev/null \
    || { say "prep: cannot write $f (need root)"; return 4; }
  "$SYSCTL" --system >/dev/null 2>&1
  # 3. assert forwarding actually took (a written file that never applied is the silent trap).
  local fw; fw="$(fwd_ok)" && say "prep: tailscale present ($("$TS" version 2>/dev/null | head -1)); IP forwarding $fw" \
    || { say "prep: IP forwarding not enabled ($fw) -- subnet router would not forward to Horizon"; return 4; }
}

install(){
  command -v "$TS" >/dev/null 2>&1 || { say "install: tailscale not present -- run 'prep $SITE' first"; return 4; }
  # Resolve the key to a FILE and hand it to tailscale via the file: scheme so the secret never
  # lands on argv / in ps (2026-08-07: an --authkey=<value> form leaked the key into ps AND a
  # session transcript). If only $TS_AUTHKEY is given, stage it to a 0600 temp file, never argv.
  local keyfile="${TS_AUTHKEY_FILE:-}" tmpkey=""
  if [ -z "$keyfile" ] && [ -n "${TS_AUTHKEY:-}" ]; then
    tmpkey="$(mktemp)"; chmod 600 "$tmpkey"; printf '%s' "$TS_AUTHKEY" > "$tmpkey"; keyfile="$tmpkey"
  fi
  { [ -n "$keyfile" ] && [ -s "$keyfile" ]; } || { say "install: no pre-auth key (\$TS_AUTHKEY_FILE / \$TS_AUTHKEY)"; return 4; }
  local tagflag=()
  [ -n "$TAG" ] && tagflag=(--advertise-tags="$TAG")
  # Subnet router = ADVERTISE ONLY. NO --accept-routes: on a DC .7 whose SSH path lives INSIDE the
  # advertised /22, consuming routes blackholed the node's own L3 and locked us out (2026-08-07
  # incident). A subnet router advertises; it does not need to consume routes. SNAT default on
  # (ruling d, no flag). --advertise-tags only when TS_TAG set (untagged VR1 by default).
  local rc=0
  "$TS" up --login-server="$LOGIN_SERVER" --authkey="file:$keyfile" \
        --advertise-routes="$METAL_ADMIN_CIDR" "${tagflag[@]}" \
        --hostname="${SITE}-tailscale" >/dev/null 2>&1 || rc=$?
  [ -n "$tmpkey" ] && rm -f "$tmpkey"
  [ "$rc" -eq 0 ] || { 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${TAG:+ as $TAG} (advertise-only)"
  say "NOTE: Headscale must APPROVE the route (manual, or autoApprovers if tagged) -- run 'check' to confirm."
}

check(){
  local rc=0 st routes tags fw
  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
  # IP forwarding -- the load-bearing property. A subnet router with forwarding off joins,
  # advertises and gets approved while forwarding NOTHING (advisor 2026-08-07). Assert it here.
  fw="$(fwd_ok)" && say "OK: IP forwarding enabled ($fw)" \
    || { say "FAIL: IP forwarding off ($fw) -- subnet router will not forward to Horizon"; rc=1; }
  if [ -n "$TAG" ]; then
    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
  else
    say "OK: untagged join (VR1 office1-mirrored; TS_TAG empty -- tagged design deferred to bare-metal, D-129(iii) amdt)"
  fi
  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
  # Own-subnet guard: the node must NOT route its OWN advertised /22 via tailscale0 -- doing so
  # blackholes its metal-admin path and locks the operator out (2026-08-07 incident, the reason
  # --accept-routes is gone). This is a green-except-route failure the other checks cannot see.
  if ip route show "$METAL_ADMIN_CIDR" 2>/dev/null | grep -q tailscale0; then
    say "FAIL: $METAL_ADMIN_CIDR routed via tailscale0 -- node is blackholing its own subnet (2026-08-07)"; rc=1
  else say "OK: own subnet $METAL_ADMIN_CIDR not routed via tailscale0"; fi
  # Control-plane reachability: a subnet router that cannot reach Headscale registers/stays down
  # while every OTHER check but the route can read green (advisor 2026-08-07). Assert a live HTTP
  # response from the control URL, not just DNS.
  local code; code="$("$CURL" -s -o /dev/null -m 8 -w '%{http_code}' "$LOGIN_SERVER" 2>/dev/null)"
  if [ -n "$code" ] && [ "$code" != 000 ]; then say "OK: control plane reachable ($LOGIN_SERVER -> HTTP $code)"
    else say "FAIL: control plane $LOGIN_SERVER unreachable (http='$code') -- node cannot register/stay up"; rc=1; fi
  return $rc
}

case "$MODE" in
  prep)    prep    || exit 4 ;;
  install) install || exit 4 ; check || { say "install ran but check not yet green (route approval pending on Headscale)"; exit 1; } ;;
  check)   check   || exit 1 ;;
esac
