From 21dfa22f9a2e17d8929c08542dac5e9f5b5e97e8 Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Sat, 1 Aug 2026 18:46:06 +0000 Subject: [PATCH] nginx: strict public default server; keep oc off 0.0.0.0 (copy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync of the canonical module. The atproto vhost listens on 0.0.0.0 only, so it never shares an address group with oc.commoninternet.net (which is the default server on 100.84.190.30:80 and has no auth in front of it). A new public-default-reject vhost owns the public addresses — return 444 on 80, ssl_reject_handshake on 443 — so unmatched, absent or spoofed Host headers from the internet get nothing, now and after any future vhost is added. The tailnet is untouched: oc.commoninternet.net still serves opencode there. Verified with tools/check-exposure.sh in the project repo — 9/9 ok. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SmEK2voMnBa23495aLk1Ce --- nix/atproto-likes.nix | 82 ++++++++++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 28 deletions(-) diff --git a/nix/atproto-likes.nix b/nix/atproto-likes.nix index cdc0320..b3c63a8 100644 --- a/nix/atproto-likes.nix +++ b/nix/atproto-likes.nix @@ -1,9 +1,9 @@ -# atproto-likes — the "most-liked accounts" web UI, run as a docker-compose stack behind nginx. +# atproto-likes — the "most-liked accounts" web UI, run as a docker-compose stack # # ⚠️ COPY. Canonical source: # /srv/project-orchestrator/projects/notplants-atproto/nix/atproto-likes.nix -# A flake cannot import an absolute path outside its own tree under pure -# evaluation, so the module is copied here. Re-copy after editing the original. +# Pure evaluation cannot import a path outside the flake tree. Re-copy after editing. +# behind the host's nginx. # # Import this from the host configuration: # imports = [ /srv/project-orchestrator/projects/notplants-atproto/nix/atproto-likes.nix ]; @@ -18,13 +18,19 @@ # DNS: atproto.commoninternet.net resolves to 143.244.213.108 — the operator's # gateway (tailnet peer `gateway-server-aug2-9pm`, 100.80.66.110), which fronts this # host rather than pointing at it directly. So what arrives here is the gateway's -# back-end hop, and the gateway needs a route for this hostname to either -# http://100.84.190.30 (tailnet, preferred) -# http://168.119.126.100 (public) -# preserving the Host header, passing Upgrade/Connection through (the loading page -# is a websocket), and allowing a long read timeout (a cold scan runs minutes). -# ACME here can only succeed if the gateway forwards /.well-known/acme-challenge/; -# otherwise the gateway should own the certificate and `enableACME` can go false. +# back-end hop. It must point at +# +# http://168.119.126.100 (the PUBLIC address — never 100.84.190.30) +# +# because the tailscale address is where the opencode UI lives and is that address +# group's default server; a hop there with a missing or wrong Host header would be +# served opencode. See the listen comments below. The gateway should also pass +# Upgrade/Connection through (the loading page is a websocket) and allow a long +# read timeout (a cold scan runs minutes). +# +# The gateway forwards everything, including /.well-known/acme-challenge/, so ACME +# HTTP-01 here will start succeeding as soon as the gateway has a route for this +# hostname. Until then nginx serves the self-signed placeholder NixOS installs. { config, pkgs, lib, ... }: let @@ -73,25 +79,23 @@ in # resolve straight back to the gateway: a redirect loop. Serve both schemes # and let the gateway decide where TLS terminates. addSSL = true; - # Keeps retrying; it can only succeed if the gateway forwards - # /.well-known/acme-challenge/ here, since the A record is the gateway's. - # Until then nginx uses the self-signed placeholder NixOS installs, and the - # acme-atproto… unit sits in `failed`. Set this false if the gateway is to - # own the certificate permanently. + # The gateway forwards everything, so once it has a route for this hostname + # the HTTP-01 challenge reaches us and this starts succeeding on its own. + # Until then the acme-atproto… unit sits in `failed` and nginx uses the + # self-signed placeholder; nothing else on the host is affected. enableACME = true; - # The `oc.commoninternet.net` vhost binds explicitly to the tailscale IP on - # port 80. nginx prefers the most specific listen address, so a request to - # 100.84.190.30:80 was only ever matched against THAT server block — this - # vhost, listening on 0.0.0.0, never got a look in, and a gateway hop over - # the tailnet was served the opencode UI instead. Listing the tailscale - # address explicitly here puts both vhosts on that socket, so server_name - # decides, which is what we want. - listen = [ - { addr = "0.0.0.0"; port = 80; ssl = false; } - { addr = "0.0.0.0"; port = 443; ssl = true; } - { addr = "100.84.190.30"; port = 80; ssl = false; } - { addr = "100.84.190.30"; port = 443; ssl = true; } - ]; + # PUBLIC INTERFACE ONLY — deliberately not the tailscale address. + # + # nginx groups servers by the connection's local address and prefers the most + # specific listen. `oc.commoninternet.net` (the opencode UI) binds explicitly + # to 100.84.190.30:80, so that address has its own group in which oc is the + # DEFAULT server. Anything arriving there without a matching Host — a proxy + # that drops the header, or sends `Host: 100.84.190.30` — is served opencode. + # So the gateway must hop to the PUBLIC address, never the tailscale one: + # this vhost lives on 0.0.0.0, where the only servers are itself and the + # reject-everything default below. Keeping the two names on disjoint + # addresses is what makes "opencode cannot leak publicly" structural rather + # than a matter of getting a Host header right. locations."/" = { proxyPass = "http://127.0.0.1:${toString hostPort}"; # The loading page streams scan progress over a websocket. @@ -104,6 +108,28 @@ in ''; }; }; + + # Strict default server for the PUBLIC addresses. Without an explicit + # default_server, nginx promotes the first server block in the group — so + # every future vhost added to 0.0.0.0 silently becomes the thing that answers + # unmatched/absent/spoofed Host headers from the internet. This closes that + # door once: only names we deliberately serve get a response here. + # + # port 80 -> 444 (close the connection, no response at all) + # port 443 -> ssl_reject_handshake, so an unknown SNI never even gets TLS + # + # It listens only on 0.0.0.0, so the tailscale address group is untouched and + # oc.commoninternet.net keeps behaving exactly as before on the tailnet. + virtualHosts."public-default-reject" = { + default = true; + serverName = null; + rejectSSL = true; + listen = [ + { addr = "0.0.0.0"; port = 80; ssl = false; } + { addr = "0.0.0.0"; port = 443; ssl = true; } + ]; + extraConfig = "return 444;"; + }; }; security.acme = {