#!/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()