feat(nh3-svos): mesh-only front door for svos.phasefinal.com (Caddy + WhoIs fail-closed auth), headscale extra record

This commit is contained in:
vh
2026-10-05 19:31:41 -07:00
parent 63736bd40a
commit ce398685c0
5 changed files with 230 additions and 1 deletions
+54
View File
@@ -0,0 +1,54 @@
# SVOS front door on nh3-svos (eshpfi configs/svos-front/), 2026-10-05. Prime: mesh-only, svos.phasefinal.com.
# - Listens ONLY on the tailnet address (100.64.0.9:443). There is no LAN or internet listener and no :80 redirect server.
# - Cert: Let's Encrypt via Cloudflare DNS-01 with a phasefinal.com-only token (env CF_API_TOKEN, /etc/caddy/env).
# - Identity: every request goes through forward_auth to svos-whois-auth on 127.0.0.1:8405, which resolves the
# SOCKET peer ({http.request.remote.host}:{port}; no trusted_proxies, so never a client header) with tailscaled
# WhoIs and returns Tailscale-User-Login only for allow-listed logins. Fail closed (403) otherwise.
# - Client-supplied identity/forwarding headers are deleted BEFORE auth; the verified login is the only one sent.
# reverse_proxy sets X-Forwarded-Proto=https and X-Forwarded-For itself; Host passes through; Origin untouched.
# - App side (svos-dev): kernel 127.0.0.1:8400 (/api/* with the prefix stripped, /api/health is the kernel's),
# connector 127.0.0.1:8403 (/health, /verbs/ping|pins|pin|unpin, /openapi.json), static UI /opt/svos/current/static.
{
email lkraven@lkraven.com
auto_https disable_redirects
admin localhost:2019
}
https://svos.phasefinal.com:443 {
bind 100.64.0.9
tls {
dns cloudflare {env.CF_API_TOKEN}
resolvers 1.1.1.1 8.8.8.8
}
route {
request_header -Tailscale-User-Login
request_header -Tailscale-User-Name
request_header -Tailscale-User-Profile-Pic
request_header -Tailscale-Headers-Info
request_header -X-Forwarded-For
request_header -X-Forwarded-Proto
request_header -X-Forwarded-Host
request_header -X-Real-IP
request_header -Forwarded
request_header -X-SVOS-Peer
forward_auth 127.0.0.1:8405 {
uri /auth
header_up X-SVOS-Peer {http.request.remote.host}:{http.request.remote.port}
copy_headers Tailscale-User-Login
}
handle_path /api/* {
reverse_proxy 127.0.0.1:8400
}
@connector path /health /verbs/ping /verbs/pins /verbs/pin /verbs/unpin /openapi.json
handle @connector {
reverse_proxy 127.0.0.1:8403
}
handle {
root * /opt/svos/current/static
try_files {path} /index.html
file_server
}
}
}
+24
View File
@@ -0,0 +1,24 @@
[Unit]
Description=SVOS front door (Caddy, tailnet-only, svos.phasefinal.com) - eshpfi configs/svos-front
After=network-online.target tailscaled.service svos-whois-auth.service
Wants=network-online.target tailscaled.service svos-whois-auth.service
[Service]
User=caddy
Group=caddy
EnvironmentFile=/etc/caddy/env
ExecStart=/usr/local/bin/caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
ExecReload=/usr/local/bin/caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/caddy
Environment=XDG_DATA_HOME=/var/lib/caddy XDG_CONFIG_HOME=/var/lib/caddy
# the tailnet address may not exist yet right after boot: keep retrying the bind
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""svos-whois-auth: Caddy forward_auth target for the SVOS front door on nh3-svos (eshpfi configs/svos-front/).
Caddy calls GET /auth with X-SVOS-Peer = the TCP peer of the accepted connection ({remote_host}:{remote_port},
taken from the socket by Caddy; never from a client header). We ask the local tailscaled (LocalAPI over its unix
socket) who that peer is and answer:
200 + Tailscale-User-Login: <LoginName> only if WhoIs resolves the peer AND the login is in the allow-list
403 on anything else: no/garbled peer, WhoIs error, unknown peer, login not
allowed, empty allow-list (fail closed)
The allow-list is /etc/svos-front/allowed-logins: one exact LoginName per line, '#' comments. Re-read per request.
Stdlib only; listens on 127.0.0.1 only.
"""
import http.client
import http.server
import ipaddress
import json
import socket
import sys
import urllib.parse
LISTEN = ("127.0.0.1", 8405)
TAILSCALED_SOCK = "/var/run/tailscale/tailscaled.sock"
ALLOW_FILE = "/etc/svos-front/allowed-logins"
TIMEOUT_S = 3.0
class _UnixHTTPConnection(http.client.HTTPConnection):
def __init__(self, path):
super().__init__("local-tailscaled.sock", timeout=TIMEOUT_S)
self._path = path
def connect(self):
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(TIMEOUT_S)
s.connect(self._path)
self.sock = s
def allowed_logins():
try:
with open(ALLOW_FILE, encoding="utf-8") as f:
return {ln.strip() for ln in f if ln.strip() and not ln.lstrip().startswith("#")}
except OSError:
return set()
def parse_peer(value):
"""'100.64.0.4:51234' or '[fd7a::4]:51234' -> canonical 'ip:port' string, else None."""
if not value:
return None
try:
if value.startswith("["):
host, _, port = value[1:].partition("]:")
else:
host, _, port = value.rpartition(":")
ip = ipaddress.ip_address(host)
port_n = int(port)
if not 0 < port_n < 65536:
return None
except ValueError:
return None
return f"[{ip}]:{port_n}" if ip.version == 6 else f"{ip}:{port_n}"
def whois(peer):
conn = _UnixHTTPConnection(TAILSCALED_SOCK)
try:
conn.request("GET", "/localapi/v0/whois?addr=" + urllib.parse.quote(peer, safe=""),
headers={"Sec-Tailscale": "localapi"})
resp = conn.getresponse()
body = resp.read()
if resp.status != 200:
return None
data = json.loads(body)
return (data.get("UserProfile") or {}).get("LoginName"), (data.get("Node") or {}).get("Name")
finally:
conn.close()
class Handler(http.server.BaseHTTPRequestHandler):
server_version = "svos-whois-auth"
def _deny(self, why):
self.log_message("DENY %s", why)
self.send_response(403)
self.send_header("Content-Length", "0")
self.end_headers()
def do_GET(self): # noqa: N802 (stdlib naming)
if self.client_address[0] != "127.0.0.1":
return self._deny("non-loopback caller")
peer = parse_peer(self.headers.get("X-SVOS-Peer", ""))
if peer is None:
return self._deny("missing or malformed X-SVOS-Peer")
try:
found = whois(peer)
except Exception as e: # tailscaled down, timeout, bad JSON: fail closed
return self._deny(f"whois error for {peer}: {e.__class__.__name__}")
if not found or not found[0]:
return self._deny(f"whois: no identity for {peer}")
login, node = found
if login not in allowed_logins():
return self._deny(f"login {login!r} (node {node}) not in {ALLOW_FILE}")
self.log_message("ALLOW %s login=%r node=%s", peer, login, node)
self.send_response(200)
self.send_header("Tailscale-User-Login", str(login))
self.send_header("Content-Length", "0")
self.end_headers()
def log_message(self, format, *args): # noqa: A002 (stdlib signature)
sys.stderr.write((format % args) + "\n")
if __name__ == "__main__":
http.server.ThreadingHTTPServer(LISTEN, Handler).serve_forever()
@@ -0,0 +1,17 @@
[Unit]
Description=SVOS front door identity check (tailscaled WhoIs, fail closed) - eshpfi configs/svos-front
After=tailscaled.service
Wants=tailscaled.service
[Service]
DynamicUser=true
ExecStart=/usr/bin/python3 /usr/local/lib/svos-front/svos-whois-auth.py
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target
+20 -1
View File
@@ -33,7 +33,26 @@ scripts/elway vh@10.100.50.43 --playbook playbooks/bootstrap-infra-ops-us
scripts/elway infra-ops@10.100.50.43 --playbook playbooks/nh3-svos-host.yaml
```
## Ingress / auth: NOT built yet, proposal pending (Prime + svos-dev)
## Front door: built 2026-10-05, mesh-only `https://svos.phasefinal.com` (Prime). Fail-closed until the owner identity is set
- **Mesh:** tailscaled joined headscale as `nh3-svos`, **100.64.0.9**, `--accept-dns=false --accept-routes=false`.
- **Name:** `svos.phasefinal.com` exists ONLY as a headscale MagicDNS extra record (`/var/lib/headscale/extra-records.json`
on nh3-headscale, hot-reloaded) → 100.64.0.9. There is no public record and no internal AdGuard record.
- **TLS:** Caddy 2.11.4 (the fleet `caddy-cf` build with the Cloudflare DNS module) at `/usr/local/bin/caddy`, unit
`caddy-svos.service`, config `/etc/caddy/Caddyfile` (canonical `configs/svos-front/Caddyfile`). It listens **only on
100.64.0.9:443**, with no `:80`. The LE cert for `svos.phasefinal.com` comes via DNS-01, token `nh3-svos-caddy-dns01`
(phasefinal.com only), in `/etc/caddy/env` (root:caddy 0640), vaulted `nh3-svos/cloudflare-caddy-dns01-token`.
- **Identity:** `svos-whois-auth.service` (`configs/svos-front/svos-whois-auth.py`, DynamicUser, 127.0.0.1:8405). Caddy's
`forward_auth` passes the SOCKET peer; the responder asks tailscaled WhoIs and answers 200 + `Tailscale-User-Login` only
for logins in **`/etc/svos-front/allowed-logins`** (empty = nobody). Client identity/forwarding headers are deleted first.
- **Tested 2026-10-05:** LAN → no connection; mesh peer fv-ml1 (login `pfi`) → 403; forged `Tailscale-User-Login` → 403;
the responder directly with a bogus, garbage or empty peer → 403. Still to run once a login is allowed: an allowed
device gets 200, its forged header is replaced, and a stopped tailscaled gives 403.
- ⚠ **Every mesh node, servers included, has WhoIs LoginName `pfi`** (headscale has one user). `pfi` must never go in
the allow-list: it would admit every server and LAN traffic SNAT'd through a subnet router. Prime's decision pending:
a separate headscale user for his devices (recommended) or a device allow-list.
### Original requirements (svos-dev)
App side, per svos-dev 2026-10-05: kernel on `127.0.0.1:8400 --trust-loopback` (its Unix-socket mode needs a 0700
kernel-owned dir and creates a 0666 socket, so no shared-group socket in this build), interface connector on