feat(nh3-svos): SVOS front door uses HTTP Basic auth (login Vuong); WhoIs gate retired
Prime's ruling: basic auth saved in the browser, mesh-only access. basic_auth argon2id with the account hash in a root:caddy file outside git (plaintext vaulted). After auth, Tailscale-User-Login is set from the authenticated user and Authorization is deleted, since the kernel's human path refuses requests that carry it. Removed svos-whois-auth (every mesh node is login pfi). Verified from a mesh peer against a temporary header echo: 401 without/with wrong credentials, 200 with exactly one Tailscale-User-Login: Vuong and no forged or Authorization headers upstream.
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
# 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.
|
||||
# - Identity: HTTP Basic auth (Prime 2026-10-05: "Basic auth is fine, saved in browser is fine"; mesh-only because we
|
||||
# don't host externally). One account, `Vuong` (Prime is how SVOS addresses him). argon2id hash in
|
||||
# /etc/caddy/svos-basic-auth.accounts (root:caddy 0640, NOT in git); plaintext vaulted nh3-svos/basic-auth-vuong.
|
||||
# The WhoIs/forward_auth gate that preceded this was retired the same day (every mesh node is login `pfi`).
|
||||
# - Client-supplied identity/forwarding headers are deleted BEFORE auth. After auth, Tailscale-User-Login is set to
|
||||
# the authenticated user ({http.auth.user.id}) and Authorization is deleted: the kernel's human path (INV-U1-06)
|
||||
# refuses any request that carries an Authorization header, and the app has no business seeing the password.
|
||||
# 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.
|
||||
@@ -30,11 +33,11 @@ https://svos.phasefinal.com:443 {
|
||||
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
|
||||
basic_auth argon2id SVOS {
|
||||
import /etc/caddy/svos-basic-auth.accounts
|
||||
}
|
||||
request_header Tailscale-User-Login {http.auth.user.id}
|
||||
request_header -Authorization
|
||||
|
||||
handle_path /api/* {
|
||||
reverse_proxy 127.0.0.1:8400
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[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
|
||||
After=network-online.target tailscaled.service
|
||||
Wants=network-online.target tailscaled.service
|
||||
|
||||
[Service]
|
||||
User=caddy
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,17 +0,0 @@
|
||||
[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
|
||||
Reference in New Issue
Block a user