diff --git a/configs/svos-front/Caddyfile b/configs/svos-front/Caddyfile new file mode 100644 index 0000000..ca0d7d5 --- /dev/null +++ b/configs/svos-front/Caddyfile @@ -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 + } + } +} diff --git a/configs/svos-front/caddy-svos.service b/configs/svos-front/caddy-svos.service new file mode 100644 index 0000000..cea7c27 --- /dev/null +++ b/configs/svos-front/caddy-svos.service @@ -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 diff --git a/configs/svos-front/svos-whois-auth.py b/configs/svos-front/svos-whois-auth.py new file mode 100644 index 0000000..96608fd --- /dev/null +++ b/configs/svos-front/svos-whois-auth.py @@ -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: 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() diff --git a/configs/svos-front/svos-whois-auth.service b/configs/svos-front/svos-whois-auth.service new file mode 100644 index 0000000..491d618 --- /dev/null +++ b/configs/svos-front/svos-whois-auth.service @@ -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 diff --git a/servers/nh3-svos/README.md b/servers/nh3-svos/README.md index 3230231..0ee3807 100644 --- a/servers/nh3-svos/README.md +++ b/servers/nh3-svos/README.md @@ -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