All notable changes to this project are documented here.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
server setup --deploy-user <name>converges a non-root sudoer under a name of your choice instead ofdeploy(handy on images that already ship anubuntuaccount). The name is persisted asdeploy_userinstate.yamland read back bydoctor, so the predicates check the account the box actually has rather than reporting drift on adeploythat was never created. A bad name (or anything at uid 0,rootincluded) is refused before any mutation, even under--dry-run. The sudoers drop-in keeps its fixed path — it's the unit's managed file, and its content carries the name — so a box can never end up with two live sudoers. Default behaviour is unchanged: no flag, stilldeploy. Validation case58-custom-deploy-user.
0.2.0 - 2026-06-29
- A profile name is now validated for format (
^[a-z][a-z0-9-]*$) at the head ofresolve_chain, before it is ever used to build a file path. Previously a name like--profile ../../etc/passwdwas only caught by the later file-existence check, so a traversal pointing at a real file could read outsideprofiles/. The format guard fires first, with a message distinct from "Unknown profile". Validation case55-fail-profile-traversalproves the refusal.
- Docs: renamed the locked spec
docs/Cahier des charges.mdtodocs/cahier-des-charges-server-setup.md(kebab-case, no spaces) to match the family's naming convention and stop breaking relative links and tooling. Updated all references (architecture doc,CLAUDE.md,bin/server).
timesyncno longer drifts on a real VPS.do_timesynconly ransystemctl enable systemd-timesyncdwithout ever installing the package, so on a box where it was absent (a fresh Hetzner image) theenablesilently no-op'd (|| true) andserver doctorreported drift. The bug was masked in CI by the validation Dockerfile, which pre-installedsystemd-timesyncdso the box lied about a fresh box's real state. Two fixes:do_timesyncnow removes a competing NTP daemon (chrony/ntp/ntpsec) if present, installssystemd-timesyncd(failing the provisioning if the install fails, instead of masking it) and unmasks the unit before enabling; the validation box dropssystemd-timesyncdand shipschronyinstead, so it reproduces a fresh Hetzner box and exercises the purge path.- The SSH cutover now refuses up front (fail-fast) when the
deployuser has noauthorized_keys, instead of switching to key-only SSH and relying on the dead-man's switch to rescue a predictable lockout. Override with the explicit--allow-keyless-ssh-cutoverwhen the key arrives out-of-band. server doctorno longer reports false timezone drift on a box converged with a non-UTC--timezone:setupnow persiststimezoneandparanoidinstate.yaml, anddoctorreads them back instead of hardcodingUTC. States written before this fix still read (fallback:UTC,paranoidderived from the sysctl drop-in).
server prune-backupspurges the timestamped backupssetupleaves under/var/backups/server-setup/. server-setup still never auto-purges (§9.6, locked): reclaiming that disk is now an explicit operator command instead of a manualrm. Two composable policies:--older-than <dur>(e.g.30d,12h,2w) deletes backups older than the cutoff,--keep <n>retains only thenmost recent. Given both,--keepis a floor--older-thancan't cross (a backup is deleted only when old AND beyond the kept set).--dry-runlists what would go without deleting (and without needing root); the real purge requires EUID 0 like every other mutation. Proven byprune_backups.bats(selection + dry-run safety + non-root refusal) and validation case57-prune-backups.server setup --profile web --ufw-dockeropts into real ufw×Docker enforcement (D8): a newufw-docker-enforceunit installs a pinned, checksum-verified copy ofufw-docker(immutable upstream commit, never acurl | bashof a movingHEAD; a checksum mismatch aborts the run) and wires itsDOCKER-USERblock into/etc/ufw/after.rulesso ufw governs the ports Docker publishes. OFF by default and never auto-enabled: without the flag the unit is skipped and only the consultativeufw-docker-guardruns, so behaviour is unchanged. The choice is recorded asufw_dockerinstate.yaml, andserver doctorreads it back to re-check the marker (skipping the unit on boxes converged without the flag). The compromise (ufw-docker rewrites ufw's tables and can surprise you) is why it's strict opt-in. Proven by validation case56-ufw-docker-enforceandconvergence.batsgating tests.server setup --authorized-keys <file>seeds thedeployuser from an explicit file of admin public keys (one per line), appended and deduped (0600, ownerdeploy). It decouplesdeployfrom whatever key root happened to have, and is what lets the SSH cutover pass its key gate without root owning a key. Without the flag, behaviour is unchanged:deployinherits root's incoming key. A bad path fails fast, before any mutation. Proven bymerge_authorized_keysunit tests and validation case54-authorized-keys-seed.- Validation case
03b-deadman-rollback: an in-container acceptance test that arms a short dead-man's window, deliberately doesn't confirm, and proves the timer FIRES and restores the pre-cutover SSH state (DoD #5). Closes the gap where the rollback's firing was exercised nowhere —convergence.batsonly rendered the rollback script, and case 03 confirmed immediately. - The
serverCLI: a desired-state convergence engine that hardens a fresh Ubuntu box and lays thepush-to-deploydoormat (install.shsymlinks it into/usr/local/bin). - Profiles
minimal → docker → webwith inheritance, parsed from manifests by a hand-rolledawkparser (noyq). server setup --profile <p>: converges the box, idempotent, writes/var/lib/server-setup/state.yaml(managed files + assertions), backs up system files before overwriting them.--dry-runon every mutating command; EUID 0 required for mutations.- The SSH cutover (root off, password off) behind the anti-lockout sequence:
sshd -ton the merged config, a loopback key self-test, a 10-minutesystemd-rundead-man's switch,server confirmto lock it in, andreload(neverrestart). server doctor: re-evaluates the converged profile's assertions (reusinglib/assert.shas the single source) and reports drift pluspush-to-deployhealth. Never mutates. Exit codes for CI,--strict.server listandserver update(self-update viagit pull --ff-only, never touches the converged box).batsunit tests of the pure logic, plus a black-boxvalidation/harness that converges a disposable systemd container through the full chain and runs in CI.- Docs: README, architecture, a page per profile, a hand-written system-overview diagram, the locked spec, and the four-repo family cross-links.
- The Friday easter egg (D14): a non-blocking duck wink on
server setup.