What this is #
The Ansible recipe that stands up srv.ordner.fyi (Hetzner Falkenstein,
Debian 13, 64 GB RAM, 2×512 GB SSD) as the new ordner host — written against
the migration guide (research/srv-migration/host-migration-guide.md, draft
v1) and the lab's actual, verified configuration. It is deliberately the same
artifact crash-recovery would run: provision a box, restore from the bucket,
re-join, done. This PR ships recipes only — nothing has been executed
against srv (no access yet).
The one hard rule is baked in everywhere it can be mistaken: the suite
hostname stays lab.ordner.fyi. The machine is srv; the install is
lab.ordner.fyi, before and after the cutover (passkeys/rpId, OIDC + atproto
client ids, the TLS lineage paths all derive from the kept name).
The files #
deploy/ansible/
site-srv.yml the srv play: provisioning + verify
render-check-srv.yml render everything locally, touch no host
RUNBOOK-srv.md per-tag runbook, pre/post operator lists, gaps
group_vars/srv.yml the [srv] group's settings (no credentials)
inventory.srv.example.ini
files/infra-crypt-env.py the one HKDF derivation, three consumers
files/prove-ptr.py §9.2 precheck (runs as a pre_task, tagged always)
files/prove-egress.py §9.7 outbound 25/465 check (verify play)
roles/knot/ new
roles/spindle/ new
roles/backup/ new
roles/nginx-srv/ new
site-srv.yml orders bundle → node → base → seaweedfs → resolver → deploy
→ knot → spindle → backup → nginx-srv, with the PTR/forward-DNS precheck
and the Debian-13 refusal as always-tagged pre_tasks, and a verify play
(--tags verify): the infrastructure checks run any time (knot health +
owner DID, spindle /_health, the podman socket, TLS routing per name,
outbound 25/465, backup timer), the suite's /status + certificate horizon
only with -e wizard_done=true — they are meaningless before the wizard.
The new roles #
knot — knot2 is the Rust crate knot-server in the tangled-core
monorepo (the guide/task's "build with Go" fits the old Go knotserver, not
knot2; see gaps). Install modes: controller (default — the lab's actual
path: built once, copied), release (URL var), source (clone +
cargo build --release -p knot-server, toolchain 1.96.0 pinned). The config
template uses the monorepo's own knot2/example.toml keys — hostname
knot.ordner.fyi, admin DID did:plc:rwypz6edxmzyhob2k3rsl2fw, loopback
listeners 5555/2222, object_format = "sha1" (CI-spindle compat),
scan_path/sealed_key_file/master_key_env per the lab's layout. The unit
adapts the monorepo's shipped systemd/knot.service hardening. The
state restore pulls knot/etc + knot/state from the backup folder
through the crypt remote; on a fresh install it renders the config, generates
an SSH host key and mints a new KNOT_MASTER_KEY (loudly: that is a new
knot, not the recovery path).
spindle — go build ./cmd/spindle with pinned Go 1.26.9 (checksum from
go.dev/dl; Debian 13's golang is below go.mod's go 1.26), git 2.56.0 built
to /opt/git-2.56.0 (checksum pinned; PATH override only inside the
unit), rootless podman (subuids, enable-linger, the user's
podman.socket), and the verified pasta fix (pasta_options = ["-a", "10.0.2.100"], array form — the map form fails to decode). The env file
renders the binary's real variable surface, read from
spindle/config/config.go: SPINDLE_SERVER_{HOSTNAME,OWNER,LISTEN_ADDR, DB_PATH,REPO_DIR,LOG_DIR,DOCKER_SOCKET}, SPINDLE_NIXERY_PIPELINES_*
(nixery.dev per the operator's direction; lab limits: 2 pipelines / 4 GiB /
15 m). Same crypt-remote restore; a restored env is authoritative and its
hostname/owner are drift-checked against group_vars.
backup — svc-infra-backup.timer (daily 03:40 + jitter, Persistent=).
The script reads the master key and the blobS3 coordinates out of
svc.json (never ships them), derives the crypt passphrase
(HKDF-SHA-256, salt 0³², info svc.backup.v1, hex), and syncs through the
crypt remote into backups/infrastructure/<host>/: the knot state, the
spindle state (SQLite via VACUUM INTO snapshots — never live db copies;
knot2 keeps no db, the loop is generic), /srv/www, and the non-secret
configs (nginx, units, letsencrypt — which keeps the DANE-pinned lineage).
Not svc.json (the phrase stays the root; the bucket must never hold a
key derived from itself) and not /opt/svc/state (the high-water
journals never travel). Retention: mirror + --backup-dir dated archive,
--min-age cleanup (30 d, var). The folder sits outside the install prefix,
so the doctor's gc never sweeps it. Pre-wizard runs exit 0 with a notice and
the timer keeps ticking.
nginx-srv — the srv variant of roles/nginx. Port-80 vhost for every
name the box answers; both lineages pre-issued via DNS-01 through the
Hover hook pattern from the lab (wildcard ordner.fyi + *.ordner.fyi, and
lab.ordner.fyi with --reuse-key — DNS-01 because pre-cutover an HTTP-01
challenge served by srv is validated against the lab; pre-issuing is the
guide §6 option that lets the wizard write the mail TLS section and lets §5's
verification windows exercise SMTP before the flip); the renewal hooks
10/20/30; the knot vhost (git over HTTPS: client_max_body_size 0, hour-long
timeouts), the spindle vhost, the static catch-all (root /srv/www/$host,
/bundles/, www→apex), and the suite vhost installed from the same
reference config the stock role ships — the lab's svc.conf content, byte for
byte, with the kept hostname substituted.
What a real run would do #
site-srv.yml (restore case: paste the master key at the prompt + vault the
infra_s3_* and hover_env inputs):
- prove the PTR forward-confirms (refuses otherwise; prints the srv-vs-lab naming note — expected pre-cutover);
- build + push bundles, install Node 26.11.1, base packages + ClamAV, weed 4.47 trio on loopback, unbound as the system resolver, deploy the suite stopped-in-wait (ten units, five reclamation timers);
- install knot + spindle (restore their states when prompted), start both, wait on their health routes;
- arm the backup timer (first real backup lands after the wizard);
- issue both TLS lineages via Hover DNS-01, install the routing table,
nginx -t, reload; - wait for admin and print the wizard URL.
Then the runbook's operator sequence: --tags verify (infra half) → the
storage phase (guide §4 — operator choice, not playbook work) → the wizard
with the lab's values → systemctl start svc-infra-backup.service → the
cutover (guide §6) → --tags verify -e wizard_done=true.
What stays manual (deliberately) #
- the setup wizard — the designed join path; the playbook must never hold the bucket credentials or the master key;
- the storage phase (cluster join vs endpoint consolidation — §4): the playbook stands a standalone loopback weed trio and makes no cross-DC port or topology decision;
- the DNS flips and the cutover freeze itself — sequential, one live node at a time (§9.6); nothing here touches the lab;
- the PTR/mail-name decision at the cutover (set the PTR to the kept name or switch the MX — §6.6) and the rpId re-bind decision (§0) before the wizard;
- the Hetzner 25/465 unblock is account-level; the recipe only verifies
it (
prove-egress.py, hard-failing in the verify play); - the disk layout (RAID1/LVM/swap — §2) from the rescue system, before the first run.
Gaps found in the guide's assumptions #
- "the recipe may build knot2 with Go" — knot2 is Rust; the Go
knotserverin the same monorepo is the old knot. The recipe's modes cover reality (controller copy / release / cargo source build). - The crypt passphrase input — the guide says a recovery re-derives the
passphrase "from the phrase", but the phrase→masterKey derivation lives
inside the wizard. The recipe derives from the
masterKeyvalue in svc.json on both the backup and the restore path, so the operator must export that value from the lab before it goes away. If the phrase itself is to be the root, the derivation has to move into the wizard's algorithm first. - The lab's spindle env carried a
DOCKER_HOSTentry (inventory open item) the binary does not read: the real key isSPINDLE_SERVER_DOCKER_SOCKET(a socket path, bind-mounted into job containers, exposed inside asDOCKER_HOST=unix://<path>). example.tomlconfirms more required knot2 fields than the lab notes recorded (ssh_host_key_file,plc_directory) — the template covers them; the restore's drift check fails the run if the lab's live config disagrees, so nothing is guessed silently.- The wizard's rpId editability (§0) and the DKIM selector (§8) remain unresolved guide items — outside playbook scope, but the cutover checklist waits on both.
Verification performed #
ansible-playbook --syntax-checkon all four plays (site.yml and render-check.yml untouched and still green).render-check-srv.ymlrenders knot/spindle units + configs, backup unit/timer/defaults and all four srv vhosts against group_vars;systemd-analyze verify+calendar(backup timer elapses shown).bash -n/sh -non the three shell scripts,py_compileon the three python files, and a functional test of the derivation helper: HKDF determinism + key separation, and both input modes (svc-json / master-key stdin) emitting the correct hetzner + backupcrypt remotes.- Config key names and env var names transcribed from the tangled-core
monorepo source (
knot2/example.toml,spindle/config/config.go,spindle/engines/nixery/engine.go), not from memory.