# 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 # 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 ]; # # What it sets up: # * the Docker daemon (this host had none before) # * 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) # # 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 # # 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 projectDir = "/srv/project-orchestrator/projects/notplants-atproto"; domain = "atproto.commoninternet.net"; hostPort = 8731; # must match the ports: mapping in docker-compose.yml compose = "${pkgs.docker-compose}/bin/docker-compose"; in { virtualisation.docker = { enable = true; # Reclaim dangling images from repeated `--build` runs. autoPrune = { enable = true; dates = "weekly"; }; }; # Build + run the compose stack. Type=oneshot with RemainAfterExit: compose # detaches, and the containers' own restart policy keeps them alive. systemd.services.atproto-likes = { description = "atproto-likes — most-liked-accounts web UI (docker compose)"; wantedBy = [ "multi-user.target" ]; after = [ "docker.service" "docker.socket" "network-online.target" ]; requires = [ "docker.service" ]; wants = [ "network-online.target" ]; path = [ pkgs.docker pkgs.docker-compose ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; WorkingDirectory = projectDir; # A cold `--build` pulls python:3.12-slim and installs pip deps. TimeoutStartSec = "1800"; ExecStart = "${compose} up -d --build --remove-orphans"; ExecStop = "${compose} down"; Restart = "on-failure"; RestartSec = "30s"; }; }; services.nginx = { 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 = 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. locations."/" = { proxyPass = "http://127.0.0.1:${toString hostPort}"; # The loading page streams scan progress over a websocket. proxyWebsockets = true; # A cold scan can run for minutes with the socket open; the default 60s # proxy read timeout would cut the loading page off mid-flower. extraConfig = '' proxy_read_timeout 1800s; proxy_send_timeout 1800s; ''; }; }; # 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 = { acceptTerms = true; defaults.email = "mfowler.email@protonmail.com"; }; # Public HTTP/HTTPS. Before this, only 22 was open on the public interface. networking.firewall.allowedTCPPorts = [ 80 443 ]; }