@@ -19,9 +19,7 @@ Let's Encrypt), orchestrated with podman systemd quadlets.
1919
2020* *Quadlets over podman-compose*: systemd-native restart-on-boot with no
2121 extra daemon, unit-level dependency ordering, and first-class
22- `AutoUpdate=registry` integration with `podman-auto-update.timer`
23- (which rolls back automatically when the updated container fails its
24- healthcheck). Requires podman >= 4.4.
22+ `AutoUpdate=registry` integration with `podman-auto-update.timer`.
2523* *Rootless*: the API executes user-supplied prover input; run both
2624 units as an unprivileged `deploy` user with linger.
2725* *Caddy over svalinn*: the estate's svalinn/vordr stack is not yet
@@ -35,72 +33,112 @@ Let's Encrypt), orchestrated with podman systemd quadlets.
3533
3634== 0. Preflight (read-only, do first)
3735
38- ssh <user>@<SERVER_IP> 'cat /etc/os-release; free -h; df -h /; nproc; ss -tlnp; podman --version'
36+ [source,console]
37+ ----
38+ ssh <user>@<SERVER_IP> 'cat /etc/os-release; free -h; df -h /; nproc; ss -tlnp; podman --version'
39+ ----
3940
4041Gates — stop and reassess if any fail:
4142
4243* Ports 80/443 unbound (`ss -tlnp` shows no listener). If something
4344 else owns them, this plan does not apply as-is.
44- * >= 2 GB RAM free for the `:latest` image (Z3 + Lean). The `:full`
45- image (Isabelle/Agda/Julia) needs >= 4 GB and is not deployed here.
46- * podman >= 4.4 for quadlets. Debian 12 ships 4.3.1 — either install a
47- newer podman or fall back to `podman generate systemd --new --files`
48- (same containers, generated units instead of quadlets).
45+ * At least 2 GB RAM. The API container is capped at 1500 MB
46+ (`PodmanArgs=--memory=1500m`), deliberately below the host total so
47+ Caddy and the OS keep headroom; raise both together if you want more.
48+ The `:full` image (Isabelle/Agda/Julia) needs >= 4 GB and is not
49+ deployed here.
50+ * *Quadlet keys are all understood by the installed podman.* Do not
51+ trust a version number for this — measure it. Copy the three unit
52+ files to the server and run the generator in dry-run mode:
53+ +
54+ [source,console]
55+ ----
56+ scp echidna.network echidna.container caddy.container <user>@<SERVER_IP>:/tmp/qcheck/
57+ ssh <user>@<SERVER_IP> 'QUADLET_UNIT_DIRS=/tmp/qcheck /usr/libexec/podman/quadlet -dryrun'
58+ ----
59+ +
60+ It must print generated `[Service]` sections for all three units with
61+ no warnings. These units were authored and dry-run verified against podman
62+ 5.4; podman 5.x is recommended and 4.x is likely to reject at least
63+ one key (`HealthOnFailure=` in particular). If the dry-run complains,
64+ use the Debian-12 fallback in the appendix instead.
4965* Image pullable anonymously:
5066+
51- podman manifest inspect ghcr.io/hyperpolymath/echidna:latest
67+ [source,console]
68+ ----
69+ podman manifest inspect ghcr.io/hyperpolymath/echidna:latest
70+ ----
5271+
5372On 401: make the GHCR package public (GitHub → Packages → echidna →
5473Package settings → visibility) or use a read-only PAT with
5574`podman login ghcr.io` as the deploy user.
5675
5776== 1. Server preparation (as root)
5877
59- apt update && apt upgrade -y
60- apt install -y podman unattended-upgrades
61- dpkg-reconfigure -f noninteractive unattended-upgrades
78+ [source,console]
79+ ----
80+ apt update && apt upgrade -y
81+ apt install -y podman unattended-upgrades ufw
82+ dpkg-reconfigure -f noninteractive unattended-upgrades
83+
84+ # Firewall: SSH + HTTP(S) only
85+ ufw default deny incoming
86+ ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp
87+ ufw enable
6288
63- # Firewall: SSH + HTTP(S) only
64- apt install -y ufw
65- ufw default deny incoming
66- ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp
67- ufw enable
89+ # Unprivileged deploy user with lingering services
90+ useradd -m -s /bin/bash deploy
91+ loginctl enable-linger deploy
6892
69- # Unprivileged deploy user with lingering services
70- useradd -m -s /bin/bash deploy
71- loginctl enable-linger deploy
93+ # Let rootless podman bind 80/443
94+ echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/50-unprivileged-ports.conf
95+ sysctl --system
7296
73- # Let rootless podman bind 80/443
74- echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/50-unprivileged-ports.conf
75- sysctl --system
97+ # Destination for the Caddyfile and the Caddy build context
98+ mkdir -p /opt/echidna && chown deploy:deploy /opt/echidna
99+ ----
76100
77- == 2. Install the deploy unit (as deploy)
101+ == 2. Install the deploy unit
78102
79- From your workstation, from this directory:
103+ Create the quadlet directory *before* copying anything into it — as
104+ the deploy user on the server:
80105
81- scp Caddyfile Containerfile.caddy deploy@<SERVER_IP>:/opt/echidna/
82- scp echidna.network echidna.container caddy.container \
83- deploy@<SERVER_IP>:~/.config/containers/systemd/
106+ [source,console]
107+ ----
108+ mkdir -p ~/.config/containers/systemd
109+ ----
84110
85- (Create the target dirs first: `sudo mkdir -p /opt/echidna && sudo chown deploy /opt/echidna`
86- and `mkdir -p ~/.config/containers/systemd` as deploy.)
111+ Then, from your workstation in this directory:
87112
88- On the server, as deploy:
113+ [source,console]
114+ ----
115+ scp Caddyfile Containerfile.caddy deploy@<SERVER_IP>:/opt/echidna/
116+ scp echidna.network echidna.container caddy.container \
117+ deploy@<SERVER_IP>:~/.config/containers/systemd/
118+ ----
89119
90- cd /opt/echidna
91- podman build -f Containerfile.caddy -t localhost/caddy-rl:2 .
92- podman pull ghcr.io/hyperpolymath/echidna:latest
93- podman image inspect --format '{{.Digest}}' ghcr.io/hyperpolymath/echidna:latest
94- # ^ record this digest in env.example / your notes for rollback
120+ Back on the server, as deploy:
95121
96- systemctl --user daemon-reload
97- systemctl --user list-unit-files | grep -E 'echidna|caddy'
122+ [source,console]
123+ ----
124+ cd /opt/echidna
125+ podman build -f Containerfile.caddy -t localhost/caddy-rl:2 .
126+ podman pull ghcr.io/hyperpolymath/echidna:latest
127+ podman image inspect --format '{{.Digest}}' ghcr.io/hyperpolymath/echidna:latest
128+ # ^ record this digest in env.example / your notes for rollback
129+
130+ systemctl --user daemon-reload
131+ systemctl --user list-unit-files | grep -E 'echidna|caddy'
132+ ----
98133
99134== 3. DNS (before starting Caddy)
100135
101136Create in the Cloudflare zone for nesy-prover.dev:
102137
103- A api <SERVER_IP> proxied=false (grey cloud), TTL 300
138+ [source]
139+ ----
140+ A api <SERVER_IP> proxied=false (grey cloud), TTL 300
141+ ----
104142
105143Grey cloud is required at launch so Caddy's Let's Encrypt HTTP-01
106144challenge reaches the server directly. (Optional later hardening:
@@ -111,71 +149,106 @@ server IP before the first Caddy start — ACME then succeeds first try.
111149
112150== 4. Bring-up and verification
113151
114- systemctl --user start echidna caddy
115- podman ps # both Up, echidna (healthy)
116- journalctl --user -u caddy -f # watch certificate issuance
152+ [source,console]
153+ ----
154+ systemctl --user start echidna caddy
155+ podman ps # both Up, echidna (healthy)
156+ journalctl --user -u caddy -f # watch certificate issuance
157+ ----
117158
118159End-to-end, from anywhere:
119160
120- curl -s https://api.nesy-prover.dev/api/health # {"status":"ok",...}
121- curl -s https://api.nesy-prover.dev/api/provers | head
122- curl -s -X POST https://api.nesy-prover.dev/api/verify \
123- -H 'content-type: application/json' \
124- -d '{"prover":"Z3","content":"(assert (forall ((x Int)) (= (+ x 0) x)))(check-sat)"}'
161+ [source,console]
162+ ----
163+ curl -s https://api.nesy-prover.dev/api/health # {"status":"ok",...}
164+ curl -s https://api.nesy-prover.dev/api/provers | head
165+ curl -s -X POST https://api.nesy-prover.dev/api/verify \
166+ -H 'content-type: application/json' \
167+ -d '{"prover":"Z3","content":"(assert (forall ((x Int)) (= (+ x 0) x)))(check-sat)"}'
168+ ----
125169
126170Controls:
127171
128- # Rate limit: burst 40 requests, expect HTTP 429 after ~30
129- for i in $(seq 1 40); do curl -s -o /dev/null -w '%{http_code} ' \
130- https://api.nesy-prover.dev/api/health; done; echo
131- # Headers: HSTS present, Server header absent
132- curl -sI https://api.nesy-prover.dev/api/health
133- # Isolation: direct API port must be unreachable from outside
134- curl -m 5 http://<SERVER_IP>:8081/api/health || echo "unreachable (correct)"
172+ [source,console]
173+ ----
174+ # Rate limit: burst 40 requests, expect HTTP 429 after ~30
175+ for i in $(seq 1 40); do curl -s -o /dev/null -w '%{http_code} ' \
176+ https://api.nesy-prover.dev/api/health; done; echo
177+
178+ # Headers: HSTS present, Server header absent
179+ curl -sI https://api.nesy-prover.dev/api/health
180+
181+ # Isolation: the API port must NOT be reachable directly. This probe is
182+ # deliberately plaintext against the raw port — it is asserting that
183+ # nothing answers there, and must fail.
184+ curl -m 5 http://<SERVER_IP>:8081/api/health && echo "REACHABLE - FIX THIS" || echo "unreachable (correct)"
185+ ----
135186
136- Reboot survival (validates linger + [Install] + Restart in one shot):
187+ Reboot survival (validates linger + ` [Install]` + Restart in one shot):
137188
138- sudo reboot
139- # after it returns:
140- podman ps && curl -s https://api.nesy-prover.dev/api/health
189+ [source,console]
190+ ----
191+ sudo reboot
192+ # after it returns:
193+ podman ps && curl -s https://api.nesy-prover.dev/api/health
194+ ----
141195
142196== 5. Updates and rollback
143197
144- Enable unattended rollout of new releases (ghcr-publish.yml pushes
145- `:latest` on every GitHub release):
146-
147- systemctl --user enable --now podman-auto-update.timer
148- podman auto-update --dry-run # see what would change
149- podman auto-update # roll out immediately
150-
151- `podman-auto-update` restarts the unit on a new image and rolls back
152- automatically if the fresh container fails its healthcheck.
153-
154- Manual rollback to a recorded digest:
155-
156- podman pull ghcr.io/hyperpolymath/echidna@<DIGEST>
157- podman tag ghcr.io/hyperpolymath/echidna@<DIGEST> ghcr.io/hyperpolymath/echidna:latest
158- systemctl --user restart echidna
159-
160- Pinned mode (opt out of auto-update): set `Image=` in
161- `echidna.container` to a `:vX.Y.Z` tag or digest, remove
162- `AutoUpdate=registry`, and bump via PR.
163-
164- == Appendix: Debian 12 fallback (podman 4.3, no quadlets)
165-
166- podman network create echidna-net
167- podman create --name echidna --network echidna-net \
168- --memory=1500m --pids-limit=512 --tmpfs /tmp:rw,size=256m \
169- --security-opt no-new-privileges \
170- --health-cmd '/app/bin/echidna --version' --health-interval 30s \
171- ghcr.io/hyperpolymath/echidna:latest server --port 8081 --host 0.0.0.0 --cors
172- podman create --name caddy --network echidna-net \
173- -p 80:80 -p 443:443 \
174- -v /opt/echidna/Caddyfile:/etc/caddy/Caddyfile:ro \
175- -v caddy-data:/data -v caddy-config:/config \
176- localhost/caddy-rl:2
177- podman generate systemd --new --files --name echidna
178- podman generate systemd --new --files --name caddy
179- mkdir -p ~/.config/systemd/user && mv container-*.service ~/.config/systemd/user/
180- systemctl --user daemon-reload
181- systemctl --user enable --now container-echidna container-caddy
198+ `ghcr-publish.yml` pushes a new `:latest` on every GitHub release.
199+ Enable unattended rollout:
200+
201+ [source,console]
202+ ----
203+ systemctl --user enable --now podman-auto-update.timer
204+ podman auto-update --dry-run # see what would change
205+ podman auto-update # roll out immediately
206+ ----
207+
208+ How the safety net actually works: `podman auto-update` pulls the new
209+ image and restarts the unit. `HealthOnFailure=kill` in
210+ `echidna.container` means a container that goes unhealthy is killed,
211+ which fails the systemd unit — and auto-update rolls the unit back to
212+ the image it was running before when the restarted unit does not come
213+ up healthy. Without that key an unhealthy-but-running container would
214+ satisfy systemd and no rollback would occur, so do not remove it.
215+
216+ Manual rollback to a recorded digest. *Stop the timer first* —
217+ otherwise its next run pulls registry `:latest` again and silently
218+ undoes the rollback:
219+
220+ [source,console]
221+ ----
222+ systemctl --user disable --now podman-auto-update.timer
223+ podman pull ghcr.io/hyperpolymath/echidna@<DIGEST>
224+ podman tag ghcr.io/hyperpolymath/echidna@<DIGEST> ghcr.io/hyperpolymath/echidna:latest
225+ systemctl --user restart echidna
226+ ----
227+
228+ Re-enable the timer only once a fixed `:latest` has been published,
229+ or switch to pinned mode permanently: set `Image=` in
230+ `echidna.container` to a `:vX.Y.Z` tag or digest, drop
231+ `AutoUpdate=registry`, and bump it via PR.
232+
233+ == Appendix: fallback for podman without quadlet support
234+
235+ [source,console]
236+ ----
237+ podman network create echidna-net
238+ podman create --name echidna --network echidna-net \
239+ --memory=1500m --pids-limit=512 --tmpfs /tmp:rw,size=256m \
240+ --security-opt no-new-privileges \
241+ --health-cmd '/app/bin/echidna --version' --health-interval 30s \
242+ --health-on-failure kill \
243+ ghcr.io/hyperpolymath/echidna:latest server --port 8081 --host 0.0.0.0 --cors
244+ podman create --name caddy --network echidna-net \
245+ -p 80:80 -p 443:443 \
246+ -v /opt/echidna/Caddyfile:/etc/caddy/Caddyfile:ro \
247+ -v caddy-data:/data -v caddy-config:/config \
248+ localhost/caddy-rl:2
249+ podman generate systemd --new --files --name echidna
250+ podman generate systemd --new --files --name caddy
251+ mkdir -p ~/.config/systemd/user && mv container-*.service ~/.config/systemd/user/
252+ systemctl --user daemon-reload
253+ systemctl --user enable --now container-echidna container-caddy
254+ ----
0 commit comments