From 095a844b3da9a73a2ffdbceb65b5c0aed2a3d886 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 28 Sep 2026 20:12:30 +0000 Subject: [PATCH] feat(dev2-site): render r4ck.dev and bittorrented.com vhosts from the kit Two vhosts could not be re-rendered, so they missed the proxy_buffer_size fix (#112): - r4ck.dev carries a hand-added ThreatCrush x402 paywall location that a re-render would delete. sites.d may now set nginx_locations (raw location blocks, __APP_PORT__ substituted), inserted ahead of the catch-all; the paywall block moves into sites.d/r4ck.dev.json. - bittorrented.com is not a Railway site, so it has no sites.json row and `dev2-site vhost` died "unknown site". site() now falls back to a sites.d file that names itself, and nginx_app: false renders only the proxies (its app runs on a droplet; only supabase.bittorrented.com is here). Proxy tuning moved into proxy_tuned(). Dry run against dev2: 74 vhosts render byte-identical to live; these two differ only by the buffer lines. Co-Authored-By: Claude Opus 5.5 (1M context) --- dev2/dev2-site | 28 +++++++++++++++++++++++++--- dev2/sites.d/bittorrented.com.json | 3 ++- dev2/sites.d/r4ck.dev.json | 4 ++++ 3 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 dev2/sites.d/r4ck.dev.json diff --git a/dev2/dev2-site b/dev2/dev2-site index 9107d53..7b779fa 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -385,7 +385,8 @@ def resolved_env_overrides(s): def with_overrides(s): """sites.d/.json is merged over the registry entry: one small file per site, so parallel migrations never edit a shared file. Keys are the entry's keys - (port, health_path, start_command, companion_commands, env_overrides, pg_service, + (port, health_path, start_command, companion_commands, env_overrides, pg_service, proxies, + nginx_locations (raw location blocks, __APP_PORT__ substituted, placed before `location /`), db, db_host, infra, branch, max_body, notes).""" p = os.path.join(SITES_D, f"{s['site']}.json") if os.path.exists(p): @@ -398,6 +399,12 @@ def site(name): # allow a service name or a repo tail for s in reg["sites"].values(): if s["service"] == name or (s["repo"] or "").split("/")[-1] == name: return with_overrides(s) + # A site that never lived on Railway (bittorrented.com: only its Supabase is + # here) has no registry row; its sites.d file is then the whole entry. + p = os.path.join(SITES_D, f"{name}.json") + if os.path.exists(p): + s = json.load(open(p)) + if s.get("site") == name: return s die(f"unknown site {name!r}; `dev2-site list`") def cmd_list(args): @@ -1036,15 +1043,30 @@ def cmd_cert(args): note(ssh("root", f"openssl x509 -in {cert_dir}/fullchain.pem -noout -subject -enddate | tr '\\n' ' '").strip()) mark(s["site"], "cert", cert_domains=domains) +def proxy_tuned(text, p): + """A proxied host (a Supabase gateway) takes big uploads and long-lived connections.""" + mb = p.get("max_body", "512m") + return text.replace("__MAX_BODY__", mb).replace("proxy_read_timeout 300s;", "proxy_read_timeout 3600s;\n proxy_send_timeout 3600s;").replace("client_max_body_size " + mb + ";", "client_max_body_size " + mb + ";\n large_client_header_buffers 8 64k;") + def cmd_vhost(args): s = site(args.site) proxies = s.get("proxies") or [] domains = [d for d in site_domains(s) if d not in {p["host"] for p in proxies}] ssh("root", f"test -s /etc/nginx/ssl/{s['site']}/fullchain.pem") if not args.no_cert_check else None - conf = tmpl("nginx-vhost.conf").replace("__SITE__", s["site"]).replace("__APP_PORT__", str(s["port"])).replace("__SERVER_NAMES__", " ".join(domains)).replace("__MAX_BODY__", s.get("max_body", "64m")) + if s.get("nginx_app") is False: # the app runs elsewhere (bittorrented.com); only its proxies live here + if not proxies: die(f"{s['site']}: nginx_app is false and there are no proxies to serve") + p0, proxies = proxies[0], proxies[1:] + conf = proxy_tuned(tmpl("nginx-vhost.conf"), p0).replace("__SITE__", s["site"]).replace("__APP_PORT__", str(p0["port"])).replace("__SERVER_NAMES__", p0["host"]) + domains = [p0["host"]] + else: + conf = tmpl("nginx-vhost.conf").replace("__SITE__", s["site"]).replace("__APP_PORT__", str(s["port"])).replace("__SERVER_NAMES__", " ".join(domains)).replace("__MAX_BODY__", s.get("max_body", "64m")) + if s.get("nginx_locations"): # site-specific locations (r4ck's paywall limits), ahead of the catch-all + anchor = " location / {\n proxy_pass" + if conf.count(anchor) != 1: die("nginx-vhost.conf: cannot find the proxied `location /` to insert nginx_locations before") + conf = conf.replace(anchor, s["nginx_locations"].replace("__APP_PORT__", str(s["port"])).rstrip("\n") + "\n\n" + anchor) for p in proxies: # one more server block per proxied host, same certificate blk = tmpl("nginx-vhost.conf").split("server {", 2)[2] # the 443 block only - blk = "server {" + blk.replace("__SITE__", s["site"]).replace("__APP_PORT__", str(p["port"])).replace("__SERVER_NAMES__", p["host"]).replace("__MAX_BODY__", p.get("max_body", "512m")).replace("proxy_read_timeout 300s;", "proxy_read_timeout 3600s;\n proxy_send_timeout 3600s;").replace("client_max_body_size " + p.get("max_body", "512m") + ";", "client_max_body_size " + p.get("max_body", "512m") + ";\n large_client_header_buffers 8 64k;") + blk = "server {" + proxy_tuned(blk, p).replace("__SITE__", s["site"]).replace("__APP_PORT__", str(p["port"])).replace("__SERVER_NAMES__", p["host"]) conf += "\n# " + p["host"] + " -> 127.0.0.1:" + str(p["port"]) + "\n" + blk conf += "\nserver {\n listen 80;\n listen [::]:80;\n server_name " + p["host"] + ";\n return 301 https://$host$request_uri;\n}\n" log(f"nginx vhost {s['site']} -> 127.0.0.1:{s['port']} ({', '.join(domains)})") diff --git a/dev2/sites.d/bittorrented.com.json b/dev2/sites.d/bittorrented.com.json index 8f2cf12..fe051ae 100644 --- a/dev2/sites.d/bittorrented.com.json +++ b/dev2/sites.d/bittorrented.com.json @@ -29,5 +29,6 @@ "port": 8290, "max_body": "512m" } - ] + ], + "nginx_app": false } diff --git a/dev2/sites.d/r4ck.dev.json b/dev2/sites.d/r4ck.dev.json new file mode 100644 index 0000000..278968b --- /dev/null +++ b/dev2/sites.d/r4ck.dev.json @@ -0,0 +1,4 @@ +{ + "notes": "nginx_locations: ThreatCrush x402 paywall-scrape limits. The limit_req zones and the $paywall_deny_ua map live in /etc/nginx/conf.d/paywall-limits.conf on dev2, not here.", + "nginx_locations": " # ThreatCrush: distributed x402 paywall-scrape mitigation (zones in\n # conf.d/paywall-limits.conf). Exact-match so ?query variants are covered.\n location = /api/v1/search {\n if ($paywall_deny_ua) { return 403; }\n limit_req zone=paywall_ip burst=5 nodelay;\n limit_req zone=paywall_all burst=100 nodelay;\n proxy_pass http://127.0.0.1:__APP_PORT__;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_set_header X-Forwarded-Host $host;\n proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection $connection_upgrade;\n proxy_read_timeout 300s;\n proxy_buffering off;\n proxy_buffer_size 64k;\n proxy_buffers 8 64k;\n proxy_busy_buffers_size 128k;\n }\n" +}