From 8eba454e22c15fc72b53d42fb5b83e899fc1687d Mon Sep 17 00:00:00 2001 From: Alex Bates Date: Wed, 3 Dec 2025 23:29:07 +0000 Subject: [PATCH] change pds handle domain to .starhaven.dev, not .pds.starhaven.dev --- secrets/kuribo/pds.env | 13 +- servers/kuribo/ondemand_tls_helper.py | 215 ++++++++++++++++++++++++++ servers/kuribo/pds.nix | 72 ++++++++- servers/kuribo/tangled.nix | 14 -- 4 files changed, 292 insertions(+), 22 deletions(-) create mode 100644 servers/kuribo/ondemand_tls_helper.py diff --git a/secrets/kuribo/pds.env b/secrets/kuribo/pds.env index c77cd26..255293c 100644 --- a/secrets/kuribo/pds.env +++ b/secrets/kuribo/pds.env @@ -3,11 +3,20 @@ PDS_ADMIN_PASSWORD=ENC[AES256_GCM,data:+U1Tw+rRcb9rPjZTsOZ9ZYdVeTRFjv8yhuSCCFIe+ PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=ENC[AES256_GCM,data:thNijhgsq106+SJVnoseWu1S8SU2AB8Z5EqjKUzMBm+29FB146dmqPOphXL5yBPDuj0gjzFvfu4W7BOAKcx7fA==,iv:zcmhJopT8WHN2GfhDGO1oYp/NeyPpXeNrg6AVmDYMGk=,tag:JLcO4aVuKMVgRU6pirks+A==,type:str] PDS_EMAIL_SMTP_URL=ENC[AES256_GCM,data:ltLt4Q7CaIL4swhDA2pBcMRR2gaMGcYw/7E7JtU4bMotEXrEO19V5ySomjbdFs3ImFzMtVVNY0am9R5Q40TZq85r6zDsoGv6,iv:Kh10CNUhkzqj5PROyFgGme0KUspZL/epxiQf2Ej0G6Y=,tag:noT7j38nip6OLbnwr8AWDQ==,type:str] PDS_EMAIL_FROM_ADDRESS=ENC[AES256_GCM,data:VxEX3on/7jQ/SXmr53bvFzd3O/xu,iv:yehoA4hxkJ6UOjv625834otS1Es4uKtarjZjKFk2sJI=,tag:XaplSBikfq97mLTq+XyOrQ==,type:str] +PDS_BLOBSTORE_S3_BUCKET=ENC[AES256_GCM,data:a2jMY4b0HZEd,iv:IL4aG6cNOI3VLS+axwr47ZLPXQ8NAz4ZmtcHsVkYZE0=,tag:PedqBtofxgtARa4L81sMOw==,type:str] +PDS_BLOBSTORE_S3_REGION=ENC[AES256_GCM,data:jA5Fbg==,iv:oa73XQbCcMuYlSaDzs6TgAxe8QkofaIbTLjGLYViGtE=,tag:RaJcADDkLBNAeP9deMqFqA==,type:str] +PDS_BLOBSTORE_S3_ENDPOINT=ENC[AES256_GCM,data:f4Sg8Zb3KK7rbi7wxDvl05YcAq7FhejgbYRwQMNkzG8jxxk=,iv:RCN8LTqEb327n0ICipVerIAHXq/EzLsDx2uPIOg0VTc=,tag:/SV9uPuAr2S3O5rRDDqwRQ==,type:str] +PDS_BLOBSTORE_S3_ACCESS_KEY_ID=ENC[AES256_GCM,data:3xbCb2QO3x/jI4W+OXTuLcA18XE=,iv:Bs9knoz+PVZRbUIF1TnVP8bvP8xbN7ItVgQHsDDkQd4=,tag:4yqYBRGHUA8KurW19G9X8A==,type:str] +PDS_BLOBSTORE_S3_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:MWIwduFqqe20aYD7dGedcQskSQ6mAoDc55YvAjn1fYxzN6RCNgE28Q==,iv:C8NEL9+3UoN2uv8XG3Bpjedb1pans8g1YuyYdnQ918I=,tag:5hN28CsG67WOaL606k5Y6w==,type:str] +PDS_HCAPTCHA_SITE_KEY=ENC[AES256_GCM,data:qkhAjbyESlfzmeeNFBvE9OmM5bsbAG/lK0TFgJ0yRpPU8sEP,iv:3LL84eHS9A8c2FFovYi/g4+NroqLesbjIFhDnqLtEnc=,tag:EMPmyYSubtK/RJsa7GglqA==,type:str] +PDS_HCAPTCHA_SECRET_KEY=ENC[AES256_GCM,data:8eO1yMUYo3wnwe0z9n4WygNOpQPBmktbLukFSvAAOyC1Bic=,iv:5VllqLj5Wn0Lfj5X/Jobtk7qSRGm3rxIZuYFSyjkfJc=,tag:GQj6h6Fmzgj15LpQVKjUiQ==,type:str] +PDS_HCAPTCHA_TOKEN_SALT=ENC[AES256_GCM,data:no+3mQ==,iv:qXz3svuAPQFEkidBIT0xND+69UjlNbwVqbIM/2rl6rE=,tag:WIP/jBABy2+k5EAD6Mb7AA==,type:str] +CLOUDFLARE_API_TOKEN=ENC[AES256_GCM,data:1evMsnfzqoQtkFyzULhcKsmvOORGvRLUbUs1BvES7jQ99PFSUQ3B9w==,iv:6R40bMGDJOkD/OH9Cwzb6jjI1Syg90WvnVxHddd4yCU=,tag:U1sY/8m4VGOxh1IYQ/P3uQ==,type:str] sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkenRnNWFlMzBIOTJsclYw\nSkw3ZW9pa0NRejRQd1FmOENTaE54UU4rcHkwCjFBSXljeTdQeGhXZDZrWS9JUkx0\nUXpxWUVKZTdGWjVLT1FRUmloMXhNdWcKLS0tIEhERVFJNU5pSU00b3MxUHB1Y280\nWTFiaTh0YXJyUXFKNGNrOE84elRONVUK20OPeWSZW2A9mTnEDfQmDc7n3jvUQhxb\nBatl6b0ismrkTWcRJK8nxImcvxBtMMCLfzK5Wt/9gBLJ6VDT6UPYFg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1h08rnd0jeddf55l6l3rf6dlwwh7mngcxy92tyz0hfysjqx4wvgrq6vmah2 sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4OVBaMzZ3UGd1R25SczNN\nd3R6bzVGTkN0SWpjb05wankvb2tpNTJvZlR3CjhwYUdHaTJ6Y1VPSTltOHhNbWdL\ncGs3OEJqaFljUFRhUVNncm13RFdETTgKLS0tIG1Mb1ZXQ3BpejdteWFkWUFyOGJu\ndFpyWkNiK2hoNlROd09xTzVueFdSUmcKDrIcoDDH2O/c9dyS/oLL0rudsrsmtOhJ\n55QagSzYouGlJbpl2xtBeUplg1WcEBX7FSW3UWFbz+Gc0/Rv76jRCA==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_1__map_recipient=age1dhxleu7puseq4fz5gprzdssprdd452kjry2n47xaqfh22p5eyqfs68zysl -sops_lastmodified=2025-12-01T18:49:36Z -sops_mac=ENC[AES256_GCM,data:tSPG0g8XpTu0IJ8GQKIUczVlreLbZ/VFncomwSVzFEIXloJ6QQsX2hobyFCW4RwovQoZnVfO4uL8Ku/SIjsLIMCejLiGXAa4r0VDDZtxhnaX7tPBecG7gE3Ke15V4bT6B9uxB7TGhJYTsTlq/tb8D7UZG2+yWudFry8ArJRFxp0=,iv:9vxvakrxx8EmNBPSY1wnoV5cHx2/8GqGhNLYHyDj74w=,tag:5NeNRKenYykPj6b13bHFOg==,type:str] +sops_lastmodified=2025-12-04T01:38:29Z +sops_mac=ENC[AES256_GCM,data:M25RTOpBGfBh6jC194BJtfMKTC4MGpOtlHQS9JamLIWz2Zgo9P0Lp92UsUcEEVUI2r3aQ+MQZVeKxmmIDPcSC+DcMY1vKJwoPRFKTifHxf2mXfj3fB8mjHJm+k2DvMFhWQ5lic5mznx08kTISwdmTZqPMybzypxzLOhM/aZm37Y=,iv:Zm8KCNSYmVbz0+inrInCC3IV0AU2mOhcr06w7APK5Vw=,tag:QwkTAYZZXVEcWNe1XbjDAQ==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.11.0 diff --git a/servers/kuribo/ondemand_tls_helper.py b/servers/kuribo/ondemand_tls_helper.py new file mode 100644 index 0000000..16eb4fb --- /dev/null +++ b/servers/kuribo/ondemand_tls_helper.py @@ -0,0 +1,215 @@ +""" +This HTTP service implements the /tls-check endpoint used by Caddy's +on_demand_tls.ask configuration. Behavior: + +- If the query parameter `domain` is ALLOWED -> return HTTP 200 OK with body "OK". +- Otherwise proxy the request (including the query string) to the real PDS + tls-check endpoint at http://127.0.0.1:/tls-check and forward + the upstream status and body. +""" + +import logging +import os +import socket +import sys +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import parse_qs, urlparse + +# Configuration (can be overridden with env vars) +PDS_PORT = int(os.environ.get("PDS_PORT", "3000")) +LISTEN_HOST = os.environ.get("LISTEN_HOST", "127.0.0.1") +LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "8081")) +TIMEOUT = float(os.environ.get("TIMEOUT", "5.0")) + +# Allowed domain values (lowercase) +ALLOWED = {"pds", "knot", "spindle"} + +# Configure logging to stderr (systemd/journal-friendly) +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + stream=sys.stderr, +) + + +def filter_response_headers(headers): + """ + Given an iterable of (header, value) pairs or a mapping-like object, + return a dict with hop-by-hop headers removed. This avoids sending + problematic headers to the client (Caddy). + """ + hop_by_hop = { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + } + result = {} + if hasattr(headers, "items"): + iterator = headers.items() + else: + iterator = headers + for k, v in iterator: + if k.lower() not in hop_by_hop: + result[k] = v + return result + + +class TLSCheckHandler(BaseHTTPRequestHandler): + # Reduce console noise from BaseHTTPRequestHandler + def log_message(self, format, *args): + # route to logging module at INFO level + logging.info("%s - %s", self.client_address[0], format % args) + + def _send(self, status, body=b"", headers=None): + # Send status, headers, and body to the client + self.send_response(status) + if headers: + for k, v in headers.items(): + # BaseHTTPRequestHandler will fold multiple headers set via send_header + # if necessary; we assume simple string values here. + try: + self.send_header(k, v) + except Exception: + # Ignore any header-setting errors; continue to send response + logging.debug("skipping header %r due to error", k) + else: + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.end_headers() + if body: + if isinstance(body, str): + body = body.encode("utf-8") + try: + self.wfile.write(body) + except BrokenPipeError: + # Client disconnected early; nothing to do + pass + + def _proxy_to_pds(self, path_with_query): + """ + Proxy a request to http://127.0.0.1:. + Returns (status, body_bytes, headers_dict). + """ + target = f"http://127.0.0.1:{PDS_PORT}{path_with_query}" + logging.debug("proxying to upstream: %s", target) + req = urllib.request.Request( + target, headers={"User-Agent": "ondemand-tls-helper/1.0"} + ) + try: + with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: + data = resp.read() + headers = filter_response_headers(resp.getheaders()) + return resp.status, data, headers + except urllib.error.HTTPError as e: + # Upstream returned an HTTP error; return its body and status + try: + data = e.read() + except Exception: + data = b"" + status = getattr(e, "code", 502) + headers = {} + logging.info("upstream returned HTTPError %s for %s", status, target) + return status, data, headers + except Exception as e: + logging.exception("error proxying to upstream %s: %s", target, e) + # Return 502 Bad Gateway + return 502, f"upstream error: {e}".encode("utf-8"), {} + + def _get_domain_param(self): + parsed = urlparse(self.path) + qs = parse_qs(parsed.query) + domain_vals = qs.get("domain") or [] + if not domain_vals: + return "" + return domain_vals[0].strip().lower() + + def do_GET(self): + parsed = urlparse(self.path) + path_with_query = parsed.path + ("?" + parsed.query if parsed.query else "") + domain = self._get_domain_param() + + if domain in ALLOWED: + logging.debug("allowed domain %r -> returning 200", domain) + return self._send(200, "OK") + + status, body, headers = self._proxy_to_pds(path_with_query) + return self._send(status, body, headers=headers) + + def do_HEAD(self): + parsed = urlparse(self.path) + path_with_query = parsed.path + ("?" + parsed.query if parsed.query else "") + domain = self._get_domain_param() + + if domain in ALLOWED: + logging.debug("allowed domain (HEAD) %r -> returning 200", domain) + return self._send(200, b"") + + status, _, headers = self._proxy_to_pds(path_with_query) + return self._send(status, b"", headers=headers) + + +def run(): + server_address = (LISTEN_HOST, LISTEN_PORT) + try: + httpd = HTTPServer(server_address, TLSCheckHandler) + except OSError as e: + logging.error("cannot bind to %s:%s: %s", LISTEN_HOST, LISTEN_PORT, e) + sys.exit(1) + + sa = httpd.socket.getsockname() + logging.info("ondemand TLS helper listening on %s:%s", sa[0], sa[1]) + try: + httpd.serve_forever() + except KeyboardInterrupt: + logging.info("shutting down on keyboard interrupt") + except Exception: + logging.exception("server crashed") + finally: + try: + httpd.server_close() + except Exception: + pass + + +if __name__ == "__main__": + # Allow override of config via arguments if invoked with simple flags: + # --pds-port N --listen-host HOST --listen-port N --timeout S + # to keep the script flexible for local testing. + args = sys.argv[1:] + it = iter(args) + while True: + try: + a = next(it) + except StopIteration: + break + if a in ("--pds-port",): + try: + PDS_PORT = int(next(it)) + except StopIteration: + break + elif a in ("--listen-host",): + try: + LISTEN_HOST = next(it) + except StopIteration: + break + elif a in ("--listen-port",): + try: + LISTEN_PORT = int(next(it)) + except StopIteration: + break + elif a in ("--timeout",): + try: + TIMEOUT = float(next(it)) + except StopIteration: + break + else: + # ignore unknown args + continue + + run() diff --git a/servers/kuribo/pds.nix b/servers/kuribo/pds.nix index 083d73e..c1ac001 100644 --- a/servers/kuribo/pds.nix +++ b/servers/kuribo/pds.nix @@ -1,4 +1,4 @@ -{ config, ... }: +{ config, pkgs, ... }: let pdsSettings = config.services.bluesky-pds.settings; in @@ -14,28 +14,58 @@ in enable = true; environmentFiles = [ config.sops.secrets.pds.path ]; settings = { + # https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/config/env.ts + PDS_PORT = 3000; PDS_HOSTNAME = "pds.starhaven.dev"; - PDS_ADMIN_EMAIL = "admin@starhaven.dev"; + PDS_CONTACT_EMAIL_ADDRESS = "admin@starhaven.dev"; + PDS_SERVICE_HANDLE_DOMAINS = ".starhaven.dev"; + + # Branding + PDS_SERVICE_NAME = "\"Star Haven\""; + PDS_HOME_URL = "https://starhaven.dev"; + #PDS_LOGO_URL + PDS_PRIMARY_COLOR = "#dbb23e"; + PDS_PRIMARY_COLOR_CONTRAST = "#000"; + + # S3 is configured in secrets + PDS_BLOBSTORE_DISK_LOCATION = null; }; }; services.caddy = { enable = true; + package = pkgs.caddy.withPlugins { + plugins = [ "github.com/caddy-dns/cloudflare@v0.2.2" ]; + hash = "sha256-ea8PC/+SlPRdEVVF/I3c1CBprlVp1nrumKM5cMwJJ3U="; + }; email = pdsSettings.PDS_ADMIN_EMAIL; globalConfig = '' on_demand_tls { - ask http://127.0.0.1:${toString pdsSettings.PDS_PORT}/tls-check + ask http://127.0.0.1:8081 } + + acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN} ''; - virtualHosts.${pdsSettings.PDS_HOSTNAME} = { - serverAliases = [ "*.${pdsSettings.PDS_HOSTNAME}" ]; + virtualHosts."*.starhaven.dev" = { extraConfig = '' tls { on_demand } - reverse_proxy http://127.0.0.1:${toString pdsSettings.PDS_PORT} + handle / { + redir https://starhaven.dev + } + + @knot host ${toString config.services.tangled.knot.server.hostname} + handle @knot { + reverse_proxy http://${toString config.services.tangled.knot.server.listenAddr} + } + + @spindle host ${toString config.services.tangled.spindle.server.hostname} + handle @spindle { + reverse_proxy http://${toString config.services.tangled.spindle.server.listenAddr} + } handle /xrpc/app.bsky.unspecced.getAgeAssuranceState { header content-type "application/json" @@ -43,9 +73,39 @@ in header access-control-allow-origin "*" respond `{"lastInitiatedAt":"2025-07-14T14:22:43.912Z","status":"assured"}` 200 } + + handle { + reverse_proxy http://127.0.0.1:${toString pdsSettings.PDS_PORT} + } ''; }; }; + systemd.services.caddy = { + after = [ + "ondemand-tls-helper.service" + "sops-nix.service" + ]; + serviceConfig.EnvironmentFile = config.sops.secrets.pds.path; + }; + + environment.etc."ondemand_tls_helper.py" = { + source = ./ondemand_tls_helper.py; + mode = "0755"; + }; + + systemd.services.ondemand-tls-helper = { + description = "On-demand TLS helper for Caddy (returns 200 for allowed domains or proxies to PDS)"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + serviceConfig = { + ExecStart = "${pkgs.python3}/bin/python3 /etc/ondemand_tls_helper.py"; + Environment = "PDS_PORT=${toString pdsSettings.PDS_PORT}"; + User = "nobody"; + Restart = "always"; + RestartSec = 5; + }; + }; networking.firewall.allowedTCPPorts = [ 80 diff --git a/servers/kuribo/tangled.nix b/servers/kuribo/tangled.nix index ef5f971..6c217a4 100644 --- a/servers/kuribo/tangled.nix +++ b/servers/kuribo/tangled.nix @@ -1,4 +1,3 @@ -{ config, ... }: let owner = "did:plc:tjgdahiw3u2djgnigyqeummg"; in @@ -26,17 +25,4 @@ in }; }; }; - - services.caddy.virtualHosts = { - ${config.services.tangled.knot.server.hostname} = { - extraConfig = '' - reverse_proxy http://${toString config.services.tangled.knot.server.listenAddr} - ''; - }; - ${config.services.tangled.spindle.server.hostname} = { - extraConfig = '' - reverse_proxy http://${toString config.services.tangled.spindle.server.listenAddr} - ''; - }; - }; } -- 2.51.2