116 lines
4.2 KiB
Python
116 lines
4.2 KiB
Python
#!/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()
|