diff --git a/nix/atproto-likes.nix b/nix/atproto-likes.nix index b3c63a8..4a0cd02 100644 --- a/nix/atproto-likes.nix +++ b/nix/atproto-likes.nix @@ -3,34 +3,34 @@ # ⚠️ COPY. Canonical source: # /srv/project-orchestrator/projects/notplants-atproto/nix/atproto-likes.nix # Pure evaluation cannot import a path outside the flake tree. Re-copy after editing. +# +# Host-specific context that is deliberately NOT in the public project repo: +# - the domain is fronted by the gateway (143.244.213.108); it forwards everything here +# - this vhost lists 0.0.0.0 only so it never shares an nginx address group with +# oc.commoninternet.net (opencode, on the tailscale address, no auth of its own) +# - /srv/project-orchestrator/projects/notplants-atproto/tools/check-exposure.sh asserts +# what this host serves publicly; run it after any nginx change # behind the host's nginx. # # Import this from the host configuration: # imports = [ /srv/project-orchestrator/projects/notplants-atproto/nix/atproto-likes.nix ]; # # What it sets up: -# * the Docker daemon (this host had none before) +# * the Docker daemon # * a systemd unit that builds and runs docker-compose.yml from the project checkout # * an nginx vhost for atproto.commoninternet.net with a Let's Encrypt cert, # proxying to the container on 127.0.0.1:8731 -# * ports 80/443 open (they were closed — only 22 was public) +# * ports 80/443 opened in the firewall # -# 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. It must point at +# DNS / TLS: point the domain's A record at this host (or at a reverse proxy that +# forwards to it). ACME HTTP-01 needs the challenge to reach this nginx, so if a +# proxy fronts the domain it must forward /.well-known/acme-challenge/ through. +# Until a certificate is issued, NixOS installs a self-signed placeholder so nginx +# still starts. # -# 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. +# A proxy in front should preserve the Host header (nginx routes by server_name), +# pass Upgrade/Connection through (the loading page is a websocket) and allow a +# long read timeout (a cold scan can run for minutes). { config, pkgs, lib, ... }: let @@ -61,6 +61,14 @@ in WorkingDirectory = projectDir; # A cold `--build` pulls python:3.12-slim and installs pip deps. TimeoutStartSec = "1800"; + # The container runs unprivileged as uid 10001 (see Dockerfile), but the + # page cache is a host bind mount, so its ownership is the host's business. + # Without this the app 500s on any actor whose cache files were written by + # an earlier root-running container. + ExecStartPre = [ + "${pkgs.coreutils}/bin/mkdir -p ${projectDir}/cache" + "${pkgs.coreutils}/bin/chown -R 10001:10001 ${projectDir}/cache" + ]; ExecStart = "${compose} up -d --build --remove-orphans"; ExecStop = "${compose} down"; Restart = "on-failure"; @@ -72,30 +80,18 @@ in enable = true; recommendedProxySettings = true; virtualHosts.${domain} = { - # addSSL, NOT forceSSL. DNS for this domain points at the gateway - # (143.244.213.108), which fronts this host — so the request that actually - # arrives here is the gateway's back-end hop. If that hop is plain HTTP and - # we answered with a 301 to https://atproto.commoninternet.net/, it would - # resolve straight back to the gateway: a redirect loop. Serve both schemes - # and let the gateway decide where TLS terminates. + # addSSL, NOT forceSSL. If a reverse proxy fronts this domain and its + # back-end hop is plain HTTP, a forced 301 to https:/// would + # resolve straight back to that proxy — a redirect loop. Serving both + # schemes lets whatever is in front decide where TLS terminates. addSSL = true; - # 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; - # 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. + # Listens on 0.0.0.0 only, and the reject-everything default server below + # owns those addresses. nginx groups server blocks by the connection's local + # address, so keeping this vhost on the public address alone means it never + # shares an address group with anything else the host may serve on another + # interface — no other service can be reached by sending this one an + # unexpected Host header. locations."/" = { proxyPass = "http://127.0.0.1:${toString hostPort}"; # The loading page streams scan progress over a websocket. @@ -109,17 +105,14 @@ 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. + # Strict default server for the public addresses. Without an explicit + # default_server, nginx promotes the first server block in the group — so any + # vhost added later silently becomes what answers unmatched, absent or spoofed + # Host headers from the internet. This closes that door once: only names + # deliberately served get a response. # # 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; @@ -134,9 +127,9 @@ in security.acme = { acceptTerms = true; - defaults.email = "mfowler.email@protonmail.com"; + defaults.email = "mfowler.email@protonmail.com"; # ACME contact }; - # Public HTTP/HTTPS. Before this, only 22 was open on the public interface. + # Public HTTP/HTTPS. networking.firewall.allowedTCPPorts = [ 80 443 ]; }