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
143 lines
6.5 KiB
Nix
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 ];
|
|
}
|