Files
cc-ci-orchestrator/nix/atproto-likes.nix
T
autonomic-botandClaude Opus 5 21dfa22f9a nginx: strict public default server; keep oc off 0.0.0.0 (copy)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmEK2voMnBa23495aLk1Ce
2026-08-01 18:46:06 +00:00

143 lines
6.5 KiB
Nix

# 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 ];
}