#!/bin/sh
# geoa-tailnet: read-only Tailworm check for one node (Geoa Tailnet Guard, https://tailnet.geoa.io).
#
# It runs seven read-only commands of the tailscale CLI (version, version --daemon, debug prefs, serve status --json,
# funnel status --json, lock status --json, status --json), reduces their output to booleans and counts on this
# machine, and prints a JSON or plain-text report. It never changes Tailscale state and never writes a file.
# It never prints keys, auth keys, login names or the raw output of any command: `debug prefs` contains this node's
# private keys, so only four fields are read from it (RunSSH, AppConnector.Advertise, OperatorUser, ControlURL).
# Node names are left out unless you pass --include-names. Nothing is sent anywhere unless you pass --submit URL.
#
# Usage: sh geoa-tailnet.sh [--json] [--include-names] [--stale-days N] [--env-file PATH] [--submit URL] [--now ISO]
# Env:   TAILSCALE=/path/to/tailscale (default: tailscale on PATH), AWK=awk
# Exit:  0 report printed, 2 tailscale CLI not found, 3 submit failed, 64 bad usage.
set -u
GEOA_CLI_VERSION=0.1.0
AWK=${AWK:-awk}
json=0; names=0; stale_days=30; env_file=; submit=; now=

say() { printf '%s\n' "$*"; }
die() { code=$1; shift; printf 'geoa-tailnet: %s\n' "$*" >&2; exit "$code"; }
usage() {
  say 'geoa-tailnet: read-only Tailworm check for this node (Geoa Tailnet Guard)'
  say 'usage: sh geoa-tailnet.sh [--json] [--include-names] [--stale-days N] [--env-file PATH] [--submit URL] [--now ISO]'
  say '  --json           machine-readable report (paste it at https://tailnet.geoa.io or into the MCP tool audit_node_report)'
  say '  --include-names  include this node name and stale node names (left out by default)'
  say '  --stale-days N   a node offline this many days counts as stale (default 30)'
  say '  --env-file PATH  read the two env knobs from this file (default: the daemon process, systemd unit,'
  say '                   /etc/default/tailscaled or the launchd plist, whichever is readable)'
  say '  --submit URL     also POST the JSON report to URL (https only, or http://127.0.0.1 / localhost)'
  say 'Read-only: runs tailscale version / debug prefs / serve status / funnel status / lock status / status only.'
}

while [ $# -gt 0 ]; do
  case $1 in
    --json) json=1 ;;
    --include-names) names=1 ;;
    --stale-days) [ $# -ge 2 ] || die 64 '--stale-days needs a number'; stale_days=$2; shift ;;
    --env-file) [ $# -ge 2 ] || die 64 '--env-file needs a path'; env_file=$2; shift ;;
    --submit) [ $# -ge 2 ] || die 64 '--submit needs a URL'; submit=$2; shift ;;
    --now) [ $# -ge 2 ] || die 64 '--now needs an ISO time'; now=$2; shift ;;
    -h|--help) usage; exit 0 ;;
    *) die 64 "unknown option: $1 (try --help)" ;;
  esac
  shift
done

case $stale_days in ''|*[!0-9]*) die 64 '--stale-days must be a whole number of days' ;; esac
[ ${#stale_days} -le 4 ] || die 64 '--stale-days is too large'
if [ -n "$submit" ]; then
  case $submit in
    https://*|http://127.0.0.1:*|http://127.0.0.1/*|http://localhost:*|http://localhost/*) ;;
    *) die 64 '--submit needs an https:// URL (or http://127.0.0.1 / http://localhost for a local preview)' ;;
  esac
  case $submit in *[!-A-Za-z0-9._~:/?#@%=+,\&]*) die 64 '--submit URL has characters that are not allowed' ;; esac
fi
[ -n "$now" ] || now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
case $now in
  [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]*) ;;
  *) die 64 '--now must look like 2026-10-25T20:00:00Z' ;;
esac

if [ -n "${TAILSCALE:-}" ]; then TS=$TAILSCALE
elif command -v tailscale >/dev/null 2>&1; then TS=tailscale
elif [ -x /Applications/Tailscale.app/Contents/MacOS/Tailscale ]; then TS=/Applications/Tailscale.app/Contents/MacOS/Tailscale
else TS=tailscale
fi
command -v "$TS" >/dev/null 2>&1 || die 2 'tailscale CLI not found (install Tailscale, or set TAILSCALE=/path/to/tailscale)'

# One read-only tailscale command, stderr folded in (warnings such as the client/daemon version mismatch).
ts_read() { "$TS" "$@" 2>&1; }
awk_c() { LC_ALL=C "$AWK" "$@"; }

# ---- JSON flattener shared by the extractors. JV[path] = "{" | "[" | "s<raw string>" | "l<literal>", path parts
# joined by \034. Lines before the first { or [ are skipped (CLI warnings); only the mismatch warning is noted.
JLIB='
BEGIN { S = "\034"; d = 0; started = 0; done = 0; bad = 0; mm = 0 }
/client version "[^"]*" != tailscaled server version "[^"]*"/ { mm = 1 }
done || bad { next }
!started {
  t = $0; sub(/^[ \t\r]+/, "", t); c = substr(t, 1, 1)
  if (c != "{" && c != "[") next
  started = 1
}
{ jscan($0) }
function jpath() {
  if (d < 1) return ""
  if (T[d] == "a") { K[d] = N[d]; N[d]++ }
  return (d == 1) ? K[d] : CP[d] S K[d]
}
function jscan(s,   i, n, c, j, e, v, p) {
  n = length(s); i = 1
  while (i <= n && !bad && !done) {
    c = substr(s, i, 1)
    if (c == " " || c == "\t" || c == "\r") { i++; continue }
    if (c == "{" || c == "[") {
      if (d >= 64) { bad = 1; break }
      p = jpath(); JV[p] = c
      d++; CP[d] = p; T[d] = (c == "{") ? "o" : "a"; N[d] = 0; EK[d] = (c == "{")
      i++; continue
    }
    if (c == "}" || c == "]") {
      if (d < 1) { bad = 1; break }
      d--; i++
      if (d == 0) done = 1
      continue
    }
    if (c == ",") { if (d >= 1 && T[d] == "o") EK[d] = 1; i++; continue }
    if (c == ":") { if (d >= 1) EK[d] = 0; i++; continue }
    if (c == "\"") {
      j = i + 1
      while (j <= n) { e = substr(s, j, 1); if (e == "\\") { j += 2; continue }; if (e == "\"") break; j++ }
      if (j > n) { bad = 1; break }
      v = substr(s, i + 1, j - i - 1); i = j + 1
      if (d >= 1 && T[d] == "o" && EK[d]) { K[d] = v; EK[d] = 0 }
      else { p = jpath(); JV[p] = "s" v }
      continue
    }
    j = i
    while (j <= n) { e = substr(s, j, 1); if (e == "," || e == "}" || e == "]" || e == " " || e == "\t" || e == "\r" || e == ":") break; j++ }
    v = substr(s, i, j - i)
    if (v !~ /^(true|false|null|-?[0-9][-+.eE0-9]*)$/) { bad = 1; break }
    p = jpath(); JV[p] = "l" v; i = j
  }
}
function jok() { return started && !bad && done && JV[""] == "{" }
function str(v) { return substr(v, 1, 1) == "s" ? substr(v, 2) : "" }
function pj(a, k,   i, out) { out = a[1]; for (i = 2; i <= k; i++) out = out S a[i]; return out }
'

# ---- debug prefs: only RunSSH, AppConnector.Advertise, OperatorUser and the ControlURL kind leave this program.
PREFS_AWK='
END {
  if (!jok()) { print "prefs_ok=0"; exit }
  print "prefs_ok=1"
  v = JV["RunSSH"]; print "run_ssh=" ((v == "ltrue") ? 1 : (v == "lfalse") ? 0 : "?")
  print "app_connector=" ((JV["AppConnector" S "Advertise"] == "ltrue") ? 1 : 0)
  u = str(JV["OperatorUser"])
  print "operator=" ((u == "") ? "none" : (u == "root" || u == "0") ? "root" : "non-root")
  cu = str(JV["ControlURL"]); sub(/\/+$/, "", cu)
  k = "unknown"
  if (cu == "" || cu ~ /^https:\/\/(controlplane|login)\.tailscale\.com(:443)?$/) k = "saas"
  else if (cu ~ /^https?:\/\//) k = "self-hosted"
  print "control_url=" k
}
'

# ---- serve / funnel status: forwards, Unix-socket backends, AllowFunnel, AcceptAppCaps per host:port.
SERVE_AWK='
END {
  if (!jok()) { print pre "_ok=0"; exit }
  print pre "_ok=1"
  conf = 0; ux = 0; tsock = 0; ub = 0; fun = 0; nf = 0; split_caps = 0
  for (p in JV) {
    m = split(p, a, S); v = JV[p]
    if (m == 2 && (a[1] == "TCP" || a[1] == "Web" || a[1] == "Services" || a[1] == "Foreground" || a[1] == "AllowFunnel")) conf = 1
    if (m >= 3 && a[m] == "TCPForward" && substr(v, 1, 1) == "s") {
      t = substr(v, 2); kind = (substr(t, 1, 5) == "unix:") ? "unix" : "tcp"
      if (kind == "unix") { ux = 1; ub = 1; if (t ~ /tailscaled\.sock(et)?$/) tsock = 1 }
      port = a[m - 1]; if (port !~ /^[0-9]+$/) port = "?"
      gsub(/[^-A-Za-z0-9._:\/@+]/, "?", t)
      if (nf < 200) { nf++; FP[nf] = port; FT[nf] = substr(t, 1, 200); FK[nf] = kind }
    }
    if (a[m] == "Proxy" && substr(v, 1, 6) == "sunix:") ub = 1
    if (m >= 2 && a[m - 1] == "AllowFunnel" && v == "ltrue") { fun = 1; hp = a[m]; sub(/^.*:/, "", hp); FUN[hp] = 1 }
    if (m >= 3 && a[m - 1] == "Handlers" && v == "{") { MG[p] = pj(a, m - 2); CAPS[p] = "" }
  }
  for (p in JV) {
    m = split(p, a, S)
    if (m >= 5 && a[m - 1] == "AcceptAppCaps" && a[m - 3] == "Handlers" && substr(JV[p], 1, 1) == "s") {
      mt = pj(a, m - 2); NC[mt]++; CL[mt, NC[mt]] = substr(JV[p], 2)
    }
  }
  for (mt in MG) {
    n = NC[mt] + 0
    for (i = 2; i <= n; i++) { x = CL[mt, i]; for (j = i - 1; j >= 1 && CL[mt, j] "" > x ""; j--) CL[mt, j + 1] = CL[mt, j]; CL[mt, j + 1] = x }
    key = ""; for (i = 1; i <= n; i++) key = key S CL[mt, i]
    g = MG[mt]
    if (g in FIRST) { if (FIRST[g] != key) split_caps = 1 } else FIRST[g] = key
  }
  for (i = 2; i <= nf; i++) {
    xp = FP[i]; xt = FT[i]; xk = FK[i]
    for (j = i - 1; j >= 1 && (FP[j] + 0 > xp + 0 || (FP[j] + 0 == xp + 0 && FT[j] "" > xt "")); j--) { FP[j + 1] = FP[j]; FT[j + 1] = FT[j]; FK[j + 1] = FK[j] }
    FP[j + 1] = xp; FT[j + 1] = xt; FK[j + 1] = xk
  }
  print pre "_configured=" conf; print pre "_unix_fwd=" ux; print pre "_tsock_fwd=" tsock; print pre "_unix_backend=" ub
  print pre "_funnel=" fun; print pre "_appcaps_split=" split_caps
  for (i = 1; i <= nf; i++) print pre "_fwd=" FP[i] "\t" FK[i] "\t" ((FP[i] in FUN) ? 1 : 0) "\t" FT[i]
}
'

# ---- lock status: on / off only (the keys in it are never printed).
LOCK_AWK='
END {
  v = JV["Enabled"]
  print "lock=" ((jok() && v == "ltrue") ? "on" : (jok() && v == "lfalse") ? "off" : "unknown")
}
'

# ---- status --json: counts, stale nodes (same rule as the web analyzer), SSH host keys, conn25 cap, control plane.
STATUS_AWK='
function floor_(x) { return (x == int(x) || x >= 0) ? int(x) : int(x) - 1 }
function civil(y, m, d,   era, yoe, doy, doe) {
  if (m <= 2) y--
  era = int(y / 400); yoe = y - era * 400
  doy = int((153 * (m + ((m > 2) ? -3 : 9)) + 2) / 5) + d - 1
  doe = yoe * 365 + int(yoe / 4) - int(yoe / 100) + doy
  return era * 146097 + doe - 719468
}
function isoms(s,   y, mo, dd, hh, mi, ss, r, f, ms, off) {
  if (s !~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]/) return ""
  y = substr(s, 1, 4) + 0; mo = substr(s, 6, 2) + 0; dd = substr(s, 9, 2) + 0
  hh = substr(s, 12, 2) + 0; mi = substr(s, 15, 2) + 0; ss = substr(s, 18, 2) + 0
  if (y < 1 || mo < 1 || mo > 12 || dd < 1 || dd > 31 || hh > 23 || mi > 59 || ss > 59) return ""
  r = substr(s, 20); ms = 0
  if (substr(r, 1, 1) == ".") {
    f = r; sub(/^\.[0-9]+/, "", r); f = substr(f, 2, length(f) - length(r) - 1)
    if (f == "") return ""
    ms = substr(f "00", 1, 3) + 0
  }
  if (r == "Z" || r == "z") off = 0
  else if (r ~ /^[-+][0-9][0-9]:[0-9][0-9]$/) { off = (substr(r, 2, 2) * 60 + substr(r, 5, 2)) * 60000; if (substr(r, 1, 1) == "-") off = -off }
  else return ""
  return civil(y, mo, dd) * 86400000 + ((hh * 60 + mi) * 60 + ss) * 1000 + ms - off
}
function nm(v,   x) { x = str(v); gsub(/[^-A-Za-z0-9 ._()+]+/, "?", x); x = substr(x, 1, 120); return (x == "") ? "unnamed" : x }
function hostname_of(k,   h, dn) {
  h = str(JV[k S "HostName"]); if (h != "") return nm("s" h)
  dn = str(JV[k S "DNSName"]); sub(/\..*$/, "", dn); return nm("s" dn)
}
END {
  ok = jok() && JV["Self"] == "{"
  print "status_ok=" (ok ? 1 : 0); print "status_mismatch=" mm
  if (!ok) exit
  nowms = isoms(now); peers = 0; online = 0; offline = 0; stl = 0; ssh = 0; c25 = 0; nsn = 0
  if (JV["Self" S "Online"] == "ltrue") online++; else offline++
  if (("Self" S "sshHostKeys" S "0") in JV) ssh++
  for (p in JV) {
    m = split(p, a, S)
    if (m == 2 && a[1] == "Peer" && JV[p] == "{") {
      peers++; on = (JV[p S "Online"] == "ltrue")
      if (on) online++; else offline++
      if ((p S "sshHostKeys" S "0") in JV) ssh++
      if (!on) {
        ls = str(JV[p S "LastSeen"]); days = ""
        if (ls != "" && substr(ls, 1, 10) != "0001-01-01") { t = isoms(ls); if (t != "" && nowms != "") days = floor_((nowms - t) / 86400000) }
        if (days == "" || days + 0 >= stale + 0) { stl++; if (names) SN[++nsn] = hostname_of(p) }
      }
    }
    if (m == 3 && a[1] == "Self" && a[2] == "CapMap" && index(a[3], "app-connectors-experimental")) c25 = 1
  }
  sfx = str(JV["MagicDNSSuffix"])
  if (sfx == "") sfx = str(JV["CurrentTailnet" S "MagicDNSSuffix"])
  if (sfx == "") { sfx = str(JV["Self" S "DNSName"]); sub(/\.$/, "", sfx); if (index(sfx, ".")) sub(/^[^.]*\./, "", sfx); else sfx = "" }
  cp = (sfx == "") ? "unknown" : (tolower(sfx) ~ /(^|\.)ts\.net$/) ? "saas" : "self-hosted"
  ver = str(JV["Version"])
  print "status_cp=" cp; print "status_version=" ver; print "peers=" peers; print "online=" online; print "offline=" offline
  print "stale=" stl; print "ssh=" ssh; print "c25=" c25
  if (names) {
    print "hostname=" hostname_of("Self")
    for (i = 2; i <= nsn; i++) { x = SN[i]; for (j = i - 1; j >= 1 && SN[j] "" > x ""; j--) SN[j + 1] = SN[j]; SN[j + 1] = x }
    for (i = 1; i <= nsn && i <= 500; i++) print "stale_name=" SN[i]
  }
}
'

# ---- tailscale version / version --daemon.
VER_AWK='
function sv(v) { sub(/^[ \t]*v?/, "", v); sub(/[^0-9.].*$/, "", v); return (v ~ /^[0-9]+\.[0-9]+\.[0-9]+/) ? v : "" }
/client version "[^"]*" != tailscaled server version "[^"]*"/ { mm = 1 }
/^Client:/ { x = $0; sub(/^Client:[ \t]*/, "", x); if (cl == "") cl = sv(x) }
/^Daemon:/ { x = $0; sub(/^Daemon:[ \t]*/, "", x); if (dm == "") dm = sv(x) }
/^v?[0-9]+\.[0-9]+/ { if (bare == "") bare = sv($1) }
END { print pre "_client=" ((cl != "") ? cl : bare); print pre "_daemon=" dm; print pre "_mismatch=" (mm ? 1 : 0) }
'

# ---- env knobs: only TS_SSH_DISABLE_FORWARDING and TS_SERVE_ALLOW_ALL_INTERFACES are read; other lines never leave grep.
knob_lines() { grep -E '^[[:space:]]*(export[[:space:]]+)?(TS_SSH_DISABLE_FORWARDING|TS_SERVE_ALLOW_ALL_INTERFACES)=' 2>/dev/null; }
KNOB_AWK='
function val(line, name,   v, q) {
  v = line; sub(/^[ \t]*(export[ \t]+)?/, "", v); v = substr(v, length(name) + 2); q = sprintf("%c", 39)
  if (substr(v, 1, 1) == "\"") { v = substr(v, 2); sub(/".*$/, "", v) }
  else if (substr(v, 1, 1) == q) { v = substr(v, 2); sub(q ".*$", "", v) }
  else sub(/[ \t#].*$/, "", v)
  return (v ~ /^(1|t|T|TRUE|true|True)$/) ? 1 : 0
}
{ x = $0; sub(/^[ \t]*(export[ \t]+)?/, "", x) }
substr(x, 1, 26) == "TS_SSH_DISABLE_FORWARDING=" { fwd = val($0, "TS_SSH_DISABLE_FORWARDING") }
substr(x, 1, 30) == "TS_SERVE_ALLOW_ALL_INTERFACES=" { aai = val($0, "TS_SERVE_ALLOW_ALL_INTERFACES") }
END { print "env_fwd_off=" (fwd + 0); print "env_allow_all=" (aai + 0) }
'
plist_env() { awk_c '/<key>/ { k = $0; sub(/^.*<key>/, "", k); sub(/<\/key>.*$/, "", k); next } /<string>/ && k != "" { v = $0; sub(/^.*<string>/, "", v); sub(/<\/string>.*$/, "", v); print k "=" v } { k = "" }' "$1" 2>/dev/null; }

env_source=none; env_kv=
if [ -n "$env_file" ]; then
  if [ -r "$env_file" ]; then env_source=file; env_kv=$(knob_lines < "$env_file" | awk_c "$KNOB_AWK"); fi
elif [ "${GEOA_ENV_DETECT:-1}" != 0 ]; then
  pid=
  if command -v pgrep >/dev/null 2>&1; then pid=$(pgrep -x tailscaled 2>/dev/null | head -n 1); fi
  if [ -n "$pid" ] && [ -r "/proc/$pid/environ" ]; then
    env_source=process; env_kv=$(tr '\000' '\n' < "/proc/$pid/environ" | knob_lines | awk_c "$KNOB_AWK")
  elif command -v systemctl >/dev/null 2>&1 && [ "$(systemctl show tailscaled -p LoadState --value 2>/dev/null)" = loaded ]; then
    env_source=systemd
    env_kv=$( {
      systemctl show tailscaled -p Environment --value 2>/dev/null | tr ' ' '\n'
      for f in $(systemctl show tailscaled -p EnvironmentFiles --value 2>/dev/null | awk_c '{ for (i = 1; i <= NF; i++) if ($i ~ /^\//) print $i }'); do
        [ -r "$f" ] && cat "$f"
      done
    } | knob_lines | awk_c "$KNOB_AWK")
  elif [ -r /etc/default/tailscaled ]; then
    env_source=file; env_kv=$(knob_lines < /etc/default/tailscaled | awk_c "$KNOB_AWK")
  elif [ -r /Library/LaunchDaemons/com.tailscale.tailscaled.plist ]; then
    env_source=launchd; env_kv=$(plist_env /Library/LaunchDaemons/com.tailscale.tailscaled.plist | knob_lines | awk_c "$KNOB_AWK")
  fi
fi

# ---- run the seven read-only commands; each output goes straight into its extractor (no temp files).
v1=$(ts_read version | awk_c -v pre=v "$VER_AWK")
v2=$(ts_read version --daemon | awk_c -v pre=vd "$VER_AWK")
kv_prefs=$(ts_read debug prefs | awk_c "$JLIB$PREFS_AWK")
kv_serve=$(ts_read serve status --json | awk_c -v pre=serve "$JLIB$SERVE_AWK")
kv_funnel=$(ts_read funnel status --json | awk_c -v pre=funnel "$JLIB$SERVE_AWK")
kv_lock=$(ts_read lock status --json | awk_c "$JLIB$LOCK_AWK")
kv_status=$(ts_read status --json | awk_c -v now="$now" -v stale="$stale_days" -v names="$names" "$JLIB$STATUS_AWK")
os=$(uname -s 2>/dev/null | tr -cd 'A-Za-z0-9._-' | cut -c1-40)

# ---- rule table generated from the knowledge base by scripts/gen-cli-rules.mjs (do not edit by hand).
geoa_rules() {
cat <<'GEOA_RULES'
T	TW-01	High	T3	Tailscale SSH: local Unix-socket forwarding (ssh -L) dials as root
R	TW-01	0	NOT_AFFECTED	version_fixed	Every node we can see runs the version that fixes this finding.
R	TW-01	1	NOT_AFFECTED	!ssh_server_on	Tailscale SSH is off on every node we can see.
R	TW-01	2	NOT_AFFECTED	policy_provided !ssh_policy_grants_access	Your policy has no Tailscale SSH rule, so no peer can open an SSH session.
R	TW-01	3	NOT_AFFECTED	ssh_forwarding_disabled	Forwarding is switched off on the node (TS_SSH_DISABLE_FORWARDING).
R	TW-01	4	AFFECTED	ssh_server_on ssh_policy_grants_access !ssh_forwarding_disabled	Tailscale SSH is on, your policy lets peers in, and forwarding is not switched off.
R	TW-01	5	CONDITIONAL	ssh_server_on	Tailscale SSH is on. Affected if any SSH rule lets a peer reach these nodes and forwarding is not switched off on them.
X	TW-01	-
T	TW-02	High	T2	Tailscale SSH: remote Unix-socket forwarding (ssh -R) replaces root-owned sockets
R	TW-02	0	NOT_AFFECTED	version_fixed	Every node we can see runs the version that fixes this finding.
R	TW-02	1	NOT_AFFECTED	!ssh_server_on	Tailscale SSH is off on every node we can see.
R	TW-02	2	NOT_AFFECTED	policy_provided !ssh_policy_grants_access	Your policy has no Tailscale SSH rule, so no peer can open an SSH session.
R	TW-02	3	NOT_AFFECTED	ssh_forwarding_disabled	Forwarding is switched off on the node (TS_SSH_DISABLE_FORWARDING).
R	TW-02	4	AFFECTED	ssh_server_on ssh_policy_grants_access !ssh_forwarding_disabled	Tailscale SSH is on, your policy lets peers in, and forwarding is not switched off.
R	TW-02	5	CONDITIONAL	ssh_server_on	Tailscale SSH is on. Affected if any SSH rule lets a peer reach these nodes and forwarding is not switched off on them.
X	TW-02	-
T	TW-03	High	T2-RACE / T3-RACE	Tailscale SSH: a race (TOCTOU) defeats the socket-path allowlist
R	TW-03	0	NOT_AFFECTED	version_fixed	Every node we can see runs the version that fixes this finding.
R	TW-03	1	NOT_AFFECTED	!ssh_server_on	Tailscale SSH is off on every node we can see.
R	TW-03	2	NOT_AFFECTED	policy_provided !ssh_policy_grants_access	Your policy has no Tailscale SSH rule, so no peer can open an SSH session.
R	TW-03	3	NOT_AFFECTED	ssh_forwarding_disabled	Forwarding is switched off on the node (TS_SSH_DISABLE_FORWARDING).
R	TW-03	4	AFFECTED	ssh_server_on ssh_policy_grants_access !ssh_forwarding_disabled	Tailscale SSH is on, your policy lets peers in, and forwarding is not switched off.
R	TW-03	5	CONDITIONAL	ssh_server_on	Tailscale SSH is on. Affected if any SSH rule lets a peer reach these nodes and forwarding is not switched off on them.
X	TW-03	-
T	TW-04	High	C1	conn25 app connectors: a user entitlement allows any egress destination
R	TW-04	0	NOT_AFFECTED	version_fixed	Every node we can see runs the version that fixes this finding.
R	TW-04	1	NOT_AFFECTED	!conn25_configured	Your policy has no conn25 (experimental app connector) configuration.
R	TW-04	2	AFFECTED	conn25_user_entitlements	Users (not only tagged devices) are entitled to conn25 apps.
R	TW-04	3	CONDITIONAL	conn25_configured	conn25 is configured. Affected for every device entitled to one of its apps.
X	TW-04	-
T	TW-05	High	T1	Tailscale SSH: session churn pins memory in the root daemon (OOM loop)
R	TW-05	0	NOT_AFFECTED	version_fixed	Every node we can see runs the version that fixes this finding.
R	TW-05	1	NOT_AFFECTED	!ssh_server_on	Tailscale SSH is off on every node we can see.
R	TW-05	2	NOT_AFFECTED	policy_provided !ssh_policy_grants_access	Your policy has no Tailscale SSH rule, so no peer can open an SSH session.
R	TW-05	3	AFFECTED	ssh_server_on ssh_policy_grants_access	Tailscale SSH is on and your policy lets peers open sessions (forwarding settings do not matter here).
R	TW-05	4	CONDITIONAL	ssh_server_on	Tailscale SSH is on. Affected if any SSH rule lets a peer open a session.
X	TW-05	-
T	TW-06	Medium-High	C2 / C3 / C4	headscale: unauthenticated memory exhaustion and onboarding lockout; DERP client response bomb
R	TW-06	0	NOT_AFFECTED	control_plane_saas	You use Tailscale's hosted control plane, so the headscale parts do not apply. (C3 needs a client pointed at a hostile relay.)
R	TW-06	1	CONDITIONAL	control_plane_headscale	You use a self-hosted control server. Affected if it is reachable from the internet (the report tested headscale 0.29.5; 0.29.6 makes no claim about this finding).
X	TW-06	-
T	TW-07	High	SERVE-F-1	tailscale serve: a TCP forward to the tailscaled socket exposes the admin API
R	TW-07	0	AFFECTED	serve_tailscaled_socket_forward	A serve TCP forward on this node points at tailscaled's own socket.
R	TW-07	1	CONDITIONAL	serve_unix_socket_forward	A serve TCP forward on this node points at a Unix socket. Affected if that socket is privileged or its path can be swapped (finding 8).
R	TW-07	2	CONDITIONAL	serve_operator_nonroot	A non-root operator on this node can change the serve config, so they could create such a forward.
R	TW-07	3	NOT_AFFECTED	!serve_unix_socket_forward !serve_operator_nonroot	No serve forward to a Unix socket on this node.
X	TW-07	-
T	TW-08	Medium	SERVE-F-2	tailscale serve: the tailscaled-socket guard is checked only at config time
R	TW-08	0	CONDITIONAL	serve_unix_backend	This node serves a Unix-socket backend. Affected if a non-root user can write to that socket's directory.
R	TW-08	1	NOT_AFFECTED	!serve_unix_backend	No Unix-socket serve backend on this node.
X	TW-08	-
T	TW-09	Medium	SERVE-F-3	Funnel: the funnel node attribute is enforced only by the CLI
R	TW-09	0	CONDITIONAL	funnel_on	Funnel is on for this node: it is reachable from the internet, and the funnel permission is enforced only by the CLI.
R	TW-09	1	NOT_AFFECTED	!funnel_on	Funnel is off on this node.
X	TW-09	-
T	TW-10	Note	SERVE-F-4	TS_SERVE_ALLOW_ALL_INTERFACES exposes serve outside the tunnel
R	TW-10	0	AFFECTED	serve_allow_all_interfaces serve_configured	TS_SERVE_ALLOW_ALL_INTERFACES is set and this node serves something.
R	TW-10	1	CONDITIONAL	serve_allow_all_interfaces	TS_SERVE_ALLOW_ALL_INTERFACES is set; any serve config added later is exposed beyond the tunnel.
R	TW-10	2	NOT_AFFECTED	!serve_allow_all_interfaces	TS_SERVE_ALLOW_ALL_INTERFACES is not set for tailscaled on this node.
X	TW-10	-
T	TW-11	Medium	SERVE-F-5	tailscale serve over HTTP/2: app-capability headers can bleed between mounts
R	TW-11	0	CONDITIONAL	serve_appcaps_split	This node serves mounts with different AcceptAppCaps on one host:port. Affected if your app authorizes by the Tailscale-App-Capabilities header.
R	TW-11	1	NOT_AFFECTED	!serve_appcaps_split	No serve host:port on this node mixes mounts with different AcceptAppCaps.
X	TW-11	-
F	version_fixed	patch,status,node
F	ssh_server_on	status,node
F	policy_provided	policy
F	ssh_policy_grants_access	policy
F	ssh_forwarding_disabled	node,node_env
F	conn25_configured	policy
F	conn25_user_entitlements	policy
F	control_plane_saas	status,node
F	control_plane_headscale	status,node
F	serve_configured	node
F	serve_tailscaled_socket_forward	node
F	serve_unix_socket_forward	node
F	serve_unix_backend	node
F	serve_operator_nonroot	node
F	funnel_on	node
F	serve_allow_all_interfaces	node,node_env
F	serve_appcaps_split	node
I	status	the output of `tailscale status --json`
I	policy	your policy file (admin console -> Access controls)
I	node	a node report from the read-only CLI (`bash cli/geoa-tailnet.sh --json`) on the node in question
I	patch	the patch watcher
I	node_env	tailscaled's environment on the node (/proc/<pid>/environ as root, the systemd unit, /etc/default/tailscaled, or the launchd plist; run the CLI as root or pass --env-file)
P	P-SSH-IDENTITY-WIDE	Identity-wide SSH rule
P	P-SSH-GROUP-WIDE	Group-wide SSH source
P	P-SSH-TAG-WIDE	Tag-wide SSH destination
P	P-SSH-ROOT	SSH rule allows root
P	P-SSH-CHECK	Long check-mode window
P	P-SSH-FWD-KEYS	Forwarding keys present (unverified)
P	P-SSH-NONE	No SSH access in this policy
P	P-CONN25	Experimental app connector (conn25) configured
P	P-APPCONN	App connectors configured
P	P-AUTOAPPROVERS-BROAD	Routes auto-approved for user identities
P	P-TAGOWNERS-LOOSE	Any member can assign a tag
P	P-FUNNEL-BROAD	Funnel allowed for every device
P	P-GRANT-WIDE	Allow-all network access
E	Export your policy file: admin console -> Access controls (https://console.tailscale.com/admin/acls) -> JSON editor -> select all, copy, save as policy.hujson, then paste it into the policy box or run the audit again with it.
B	1.98.10
GEOA_RULES
}

# ---- facts, verdicts and the report. Same rules and the same tri-state logic as the web engine (src/analyze/rules.mjs).
REPORT_AWK='
function je(s,   i, n, c, o) {
  if (s !~ /[\\"]/ && s !~ /[^ -~]/) return "\"" s "\""
  o = ""; n = length(s)
  for (i = 1; i <= n; i++) {
    c = substr(s, i, 1)
    if (c == "\\") o = o "\\\\"; else if (c == "\"") o = o "\\\""; else if (c == "\t") o = o "\\t"; else if (c == "\n") o = o "\\n"
    else if (c < " ") o = o "?"; else o = o c
  }
  return "\"" o "\""
}
function jb(v) { return (v == "1") ? "true" : (v == "0") ? "false" : "null" }
function jn(v) { return (v ~ /^[0-9]+$/) ? v + 0 : "null" }
function vparts(v, p) { return split(v, p, ".") }
function vcmp(a, b,   p, q, i) {
  if (a == "" || b == "") return ""
  vparts(a, p); vparts(b, q)
  for (i = 1; i <= 3; i++) { if (p[i] + 0 < q[i] + 0) return -1; if (p[i] + 0 != q[i] + 0) return 1 }
  return 0
}
function short_(v) { sub(/-.*$/, "", v); return (v ~ /^[0-9]+\.[0-9]+\.[0-9]+$/) ? v : "" }
function tf(c) { return c ? "1" : "0" }
section == 0 && $0 == "@@RULES" { section = 1; FS = "\t"; next }
section == 0 {
  k = $0; sub(/=.*$/, "", k); v = substr($0, length(k) + 2)
  if (k == "serve_fwd") { nfwd++; FWD[nfwd] = v }
  else if (k == "stale_name") { nsn++; SNM[nsn] = v }
  else KV[k] = v
  next
}
$1 == "T" { nf++; FID[nf] = $2; SEV[$2] = $3; LAB[$2] = $4; TIT[$2] = $5; next }
$1 == "R" { NR_[$2]++; RI[$2, NR_[$2]] = $3; RV[$2, NR_[$2]] = $4; RL[$2, NR_[$2]] = $5; RB[$2, NR_[$2]] = $6; next }
$1 == "N" { FB[$2] = $3; next }
$1 == "X" { FIX[$2] = $3; next }
$1 == "F" { nfact++; FACT[nfact] = $2; FIN[$2] = $3; next }
$1 == "I" { INP[$2] = $3; next }
$1 == "P" { np++; PID[np] = $2; PTI[np] = $3; next }
$1 == "E" { EXPORT = $2; next }
$1 == "B" { FLOOR = $2; next }
END {
  st = (KV["status_ok"] == "1"); pr = (KV["prefs_ok"] == "1"); sv_ok = (KV["serve_ok"] == "1"); fu_ok = (KV["funnel_ok"] == "1")
  envs = KV["env_source"]; env_ok = (envs != "none")
  run = KV["run_ssh"]; if (!pr) run = "?"
  ssh = st ? KV["ssh"] + 0 : ""
  tssh = (run == "1") ? "on" : (run == "0") ? "off" : "unknown"
  cp = (st && KV["status_cp"] != "unknown") ? KV["status_cp"] : pr ? KV["control_url"] : "unknown"
  if (cp != "saas" && cp != "self-hosted") cp = "unknown"
  op = pr ? KV["operator"] : "unknown"
  funnel = ((sv_ok && KV["serve_funnel"] == "1") || (fu_ok && KV["funnel_funnel"] == "1")) ? "on" : (sv_ok || fu_ok) ? "off" : "unknown"
  client = short_(KV["v_client"]); if (client == "") client = short_(KV["vd_client"])
  daemon = short_(KV["vd_daemon"]); if (daemon == "" && st) daemon = short_(KV["status_version"])
  warn = (KV["vd_mismatch"] == "1" || KV["status_mismatch"] == "1")
  mism = warn ? "1" : (client != "" && daemon != "") ? tf(client != daemon) : ""
  floor_cmp = vcmp(daemon, FLOOR); below = (floor_cmp == "") ? "" : tf(floor_cmp < 0)
  appc = pr ? KV["app_connector"] : ""
  lock = KV["lock"]; if (lock != "on" && lock != "off") lock = "unknown"

  F["policy_provided"] = "0"; F["ssh_policy_grants_access"] = ""; F["conn25_user_entitlements"] = ""
  F["ssh_server_on"] = (run == "1" || (st && ssh > 0)) ? "1" : (run == "0" && st && ssh == 0) ? "0" : ""
  F["ssh_forwarding_disabled"] = env_ok ? KV["env_fwd_off"] : ""
  F["conn25_configured"] = (st && KV["c25"] == "1") ? "1" : ""
  F["control_plane_saas"] = (cp == "unknown") ? "" : tf(cp == "saas")
  F["control_plane_headscale"] = (cp == "unknown") ? "" : tf(cp == "self-hosted")
  F["serve_configured"] = sv_ok ? KV["serve_configured"] : ""
  F["serve_tailscaled_socket_forward"] = sv_ok ? KV["serve_tsock_fwd"] : ""
  F["serve_unix_socket_forward"] = sv_ok ? KV["serve_unix_fwd"] : ""
  F["serve_unix_backend"] = sv_ok ? KV["serve_unix_backend"] : ""
  F["serve_operator_nonroot"] = (op == "non-root") ? "1" : (op == "none" || op == "root") ? "0" : ""
  F["funnel_on"] = (funnel == "on") ? "1" : (funnel == "off") ? "0" : ""
  F["serve_allow_all_interfaces"] = env_ok ? KV["env_allow_all"] : ""
  F["serve_appcaps_split"] = sv_ok ? KV["serve_appcaps_split"] : ""
  SUP["status"] = st; SUP["node"] = pr; SUP["node_env"] = env_ok; SUP["policy"] = 0

  for (i = 1; i <= nf; i++) {
    id = FID[i]; fx = FIX[id]
    F["version_fixed"] = (fx == "-" || fx == "") ? "0" : (daemon == "") ? "" : tf(vcmp(daemon, fx) >= 0)
    VER[id] = ""; nm_ = 0; delete SEEN
    for (r = 1; r <= NR_[id]; r++) {
      nl = split(RL[id, r], L, " "); val = 1; nu = 0
      for (j = 1; j <= nl; j++) {
        neg = (substr(L[j], 1, 1) == "!"); f = neg ? substr(L[j], 2) : L[j]; x = F[f]
        if (x == "") { nu++; U[nu] = f; if (val == 1) val = 2 }
        else if ((x == "1") == neg) val = 0
      }
      if (val == 1) { VER[id] = RV[id, r]; RULE[id] = RI[id, r]; BEC[id] = RB[id, r]; break }
      if (val == 2) for (j = 1; j <= nu; j++) if (!(U[j] in SEEN)) { SEEN[U[j]] = 1; nm_++; MISS[id, nm_] = U[j] }
    }
    NMISS[id] = 0
    if (VER[id] == "") {
      RULE[id] = "null"
      if (nm_ > 0) {
        VER[id] = "UNKNOWN"; NMISS[id] = nm_; ni = 0; delete ISEEN; txt = ""
        for (j = 1; j <= nm_; j++) {
          n = split(FIN[MISS[id, j]], IN, ","); pick = ""; first = ""
          for (q = 1; q <= n; q++) if (IN[q] != "patch") { if (first == "") first = IN[q]; if (pick == "" && !SUP[IN[q]]) pick = IN[q] }
          if (pick == "") pick = first
          if (pick != "" && !(pick in ISEEN)) { ISEEN[pick] = 1; ni++; MIN[id, ni] = pick; txt = txt ((ni > 1) ? " and " : "") INP[pick] }
        }
        NIN[id] = ni; BEC[id] = "Cannot decide without " txt "."
      } else { VER[id] = "NOT_AFFECTED"; BEC[id] = (id in FB) ? FB[id] : "None of the conditions for this finding hold." }
    }
  }

  if (json) report_json(); else report_text()
}
function report_json(   i, j, id, sep, a) {
  printf "{\n  \"tool\": \"geoa-tailnet-cli\",\n  \"version\": %s,\n  \"schema\": 1,\n  \"generated_at\": %s,\n  \"os\": %s,\n  \"read_only\": true,\n", je(KV["cli_version"]), je(KV["now"]), je(KV["os"])
  printf "  \"tailscale_ssh\": %s,\n  \"ssh_nodes\": %s,\n  \"app_connector\": %s,\n", je(tssh), (st ? ssh : "null"), jb(appc)
  printf "  \"serve_configured\": %s,\n  \"serve_tcp_forwards\": [", jb(F["serve_configured"])
  for (i = 1; i <= nfwd; i++) {
    split(FWD[i], a, "\t")
    printf "%s\n    {\"port\": %s, \"target\": %s, \"kind\": %s, \"funnel\": %s}", ((i > 1) ? "," : ""), je(a[1]), je(a[4]), je(a[2]), jb(a[3])
  }
  printf "%s],\n", (nfwd ? "\n  " : "")
  printf "  \"serve_unix_socket_forward\": %s,\n  \"serve_tailscaled_socket_forward\": %s,\n  \"serve_unix_backend\": %s,\n  \"serve_appcaps_split\": %s,\n", jb(F["serve_unix_socket_forward"]), jb(F["serve_tailscaled_socket_forward"]), jb(F["serve_unix_backend"]), jb(F["serve_appcaps_split"])
  printf "  \"serve_allow_all_interfaces\": %s,\n  \"ssh_forwarding_disabled\": %s,\n  \"env_source\": %s,\n", (env_ok ? jb(F["serve_allow_all_interfaces"]) : "\"unknown\""), (env_ok ? jb(F["ssh_forwarding_disabled"]) : "\"unknown\""), je(envs)
  printf "  \"funnel\": %s,\n  \"operator_user\": %s,\n  \"tailnet_lock\": %s,\n  \"control_plane\": %s,\n  \"conn25_on_this_node\": %s,\n", je(funnel), je(op), je(lock), je(cp), (st ? jb(KV["c25"]) : "null")
  printf "  \"client_version\": %s,\n  \"daemon_version\": %s,\n  \"client_daemon_mismatch\": %s,\n  \"below_bulletin_floor\": %s,\n  \"bulletin_floor\": %s,\n", ((client == "") ? "null" : je(client)), ((daemon == "") ? "null" : je(daemon)), jb(mism), jb(below), je(FLOOR)
  printf "  \"nodes_total\": %s,\n  \"peers\": %s,\n  \"online_nodes\": %s,\n  \"offline_nodes\": %s,\n  \"stale_nodes\": %s,\n  \"stale_days\": %s,\n", (st ? KV["peers"] + 1 : "null"), (st ? jn(KV["peers"]) : "null"), (st ? jn(KV["online"]) : "null"), (st ? jn(KV["offline"]) : "null"), (st ? jn(KV["stale"]) : "null"), jn(KV["stale_days"])
  if (KV["names"] == "1") {
    printf "  \"hostname\": %s,\n  \"stale_node_names\": [", ((st && ("hostname" in KV)) ? je(KV["hostname"]) : "null")
    for (i = 1; i <= nsn; i++) printf "%s%s", ((i > 1) ? ", " : ""), je(SNM[i])
    printf "],\n"
  }
  printf "  \"supplied\": {\"status\": %s, \"node\": %s, \"node_env\": %s, \"policy\": false},\n", (st ? "true" : "false"), (pr ? "true" : "false"), (env_ok ? "true" : "false")
  printf "  \"facts\": {"
  sep = ""
  for (i = 1; i <= nfact; i++) if (FACT[i] != "version_fixed") { printf "%s\n    %s: %s", sep, je(FACT[i]), jb(F[FACT[i]]); sep = "," }
  printf "\n  },\n  \"findings\": ["
  for (i = 1; i <= nf; i++) {
    id = FID[i]
    printf "%s\n    {\"id\": %s, \"label\": %s, \"severity\": %s, \"title\": %s, \"verdict\": %s, \"rule\": %s, \"because\": %s, \"missing\": [", ((i > 1) ? "," : ""), je(id), je(LAB[id]), je(SEV[id]), je(TIT[id]), je(VER[id]), RULE[id], je(BEC[id])
    for (j = 1; j <= NMISS[id]; j++) printf "%s%s", ((j > 1) ? ", " : ""), je(MISS[id, j])
    printf "], \"missing_inputs\": ["
    for (j = 1; j <= NMISS[id] && j <= NIN[id]; j++) printf "%s%s", ((j > 1) ? ", " : ""), je(MIN[id, j])
    printf "]}"
  }
  printf "\n  ],\n  \"policy_findings\": ["
  for (i = 1; i <= np; i++) printf "%s\n    {\"id\": %s, \"title\": %s, \"verdict\": \"UNKNOWN\", \"because\": %s}", ((i > 1) ? "," : ""), je(PID[i]), je(PTI[i]), je("Cannot decide without " INP["policy"] ".")
  printf "\n  ],\n  \"policy_export_instruction\": %s,\n", je(EXPORT)
  printf "  \"notes\": [\n    %s,\n    %s,\n    %s\n  ]\n}\n", je("Read-only: this report came from tailscale version, debug prefs (four fields), serve/funnel/lock status and status --json on this node."), je("Policy findings need your policy file; tailscaled environment knobs need root or --env-file."), je("Paste this JSON at https://tailnet.geoa.io (Check a node) or pass it to the MCP tool audit_node_report for the fix pack.")
}
function yn(v) { return (v == "1") ? "yes" : (v == "0") ? "no" : "unknown" }
function vt(v) { return (v == "NOT_AFFECTED") ? "NOT AFFECTED" : v }
function report_text(   i, a, id, pad) {
  print "Geoa Tailnet Guard: read-only node check " KV["cli_version"] " (" KV["now"] ", " KV["os"] ")"
  print ""
  print "Tailscale client " ((client == "") ? "unknown" : client) ", daemon " ((daemon == "") ? "unknown" : daemon) ((mism == "1") ? "  (client and daemon differ: upgrade or remove the older CLI)" : "")
  if (below == "1") print "Daemon is older than " FLOOR " (TS-2026-004 / TS-2026-005 fixes missing): upgrade"
  print "Control plane: " ((cp == "saas") ? "Tailscale hosted" : (cp == "self-hosted") ? "self-hosted (headscale or other)" : "unknown")
  print "Tailscale SSH on this node: " tssh
  print "Nodes advertising Tailscale SSH: " (st ? ssh : "unknown")
  print "App connector on this node: " yn(appc)
  print "Serve configured: " yn(F["serve_configured"]) "; funnel: " funnel "; operator user: " op
  for (i = 1; i <= nfwd; i++) { split(FWD[i], a, "\t"); print "  serve TCP forward: port " a[1] " to " a[2] " " a[4] ((a[3] == "1") ? " (FUNNEL: public)" : " (tailnet only)") }
  print "Serve backend on a Unix socket: " yn(F["serve_unix_backend"]) "; forward to a Unix socket: " yn(F["serve_unix_socket_forward"])
  print "Env knobs (" envs "): TS_SSH_DISABLE_FORWARDING " yn(F["ssh_forwarding_disabled"]) ", TS_SERVE_ALLOW_ALL_INTERFACES " yn(F["serve_allow_all_interfaces"])
  print "Tailnet Lock: " lock
  if (st) {
    print "Nodes: " (KV["peers"] + 1) " (" KV["online"] " online, " KV["offline"] " offline)"
    print "Stale nodes (offline " KV["stale_days"] "+ days): " KV["stale"]
  } else print "Nodes: unknown (status --json failed: is tailscaled running?)"
  print ""
  print "Findings (from this node; verdicts need the policy file for the full picture):"
  for (i = 1; i <= nf; i++) {
    id = FID[i]; pad = substr("             ", 1, 13 - length(vt(VER[id])))
    print id " " vt(VER[id]) pad LAB[id] ": " TIT[id]
    print "      " BEC[id]
  }
  print ""
  print "Policy checks: " np " checks are UNKNOWN until you supply the policy file."
  print EXPORT
  print ""
  print "Next: run again with --json and paste the result at https://tailnet.geoa.io for the review-only fix pack."
}
'

report() {
  {
    say "$v1"; say "$v2"; say "$kv_prefs"; say "$kv_serve"; say "$kv_funnel"; say "$kv_lock"; say "$kv_status"
    say "env_source=$env_source"; [ -n "$env_kv" ] && say "$env_kv"
    say "cli_version=$GEOA_CLI_VERSION"; say "now=$now"; say "os=$os"; say "stale_days=$stale_days"; say "names=$names"
    say '@@RULES'
    geoa_rules
  } | awk_c -v json="$1" "$REPORT_AWK"
}

if [ "$json" = 1 ]; then report 1; else report 0; fi
if [ -n "$submit" ]; then
  command -v curl >/dev/null 2>&1 || die 3 'curl is needed for --submit'
  if report 1 | curl -fsS --max-time 20 -H 'Content-Type: application/json' --data-binary @- "$submit" 1>&2; then
    printf '\ngeoa-tailnet: report sent to %s\n' "$submit" >&2
  else
    die 3 "submit to $submit failed"
  fi
fi
exit 0
