|
| 1 | +// SPDX-License-Identifier: MPL-2.0 |
| 2 | +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> |
| 3 | += Deploying the ECHIDNA API to Hetzner (api.nesy-prover.dev) |
| 4 | +:toc: |
| 5 | +:toclevels: 2 |
| 6 | + |
| 7 | +This directory is the complete deploy unit for running the public |
| 8 | +ECHIDNA API on a single Hetzner server behind Caddy (auto-TLS via |
| 9 | +Let's Encrypt), orchestrated with podman systemd quadlets. |
| 10 | + |
| 11 | +.What runs where |
| 12 | +[cols="1,3"] |
| 13 | +|=== |
| 14 | +| `echidna` | `ghcr.io/hyperpolymath/echidna:latest`, `server --port 8081 --host 0.0.0.0 --cors`. No published host port — reachable only from Caddy over the `echidna-net` podman network. |
| 15 | +| `caddy` | `localhost/caddy-rl:2` (built on-box from `Containerfile.caddy`), ports 80/443, terminates TLS for `api.nesy-prover.dev`, rate-limits (30 req/min/IP), caps request bodies at 1 MB. |
| 16 | +|=== |
| 17 | + |
| 18 | +== Decision record |
| 19 | + |
| 20 | +* *Quadlets over podman-compose*: systemd-native restart-on-boot with no |
| 21 | + extra daemon, unit-level dependency ordering, and first-class |
| 22 | + `AutoUpdate=registry` integration with `podman-auto-update.timer`. |
| 23 | +* *Rootless*: the API executes user-supplied prover input; run both |
| 24 | + units as an unprivileged `deploy` user with linger. |
| 25 | +* *Caddy over svalinn*: the estate's svalinn/vordr stack is not yet |
| 26 | + CI-proven; Caddy is the low-risk choice for a public endpoint. The |
| 27 | + `container/compose.toml` selur stack remains for local development. |
| 28 | +* *No API auth at launch*: the service is deliberately public but caged |
| 29 | + (rate limit, body cap, memory/pids limits, tmpfs, unpublished port). |
| 30 | + If abuse appears, add a bearer-token matcher in the Caddyfile for |
| 31 | + POST routes. A server-side per-request prover timeout is tracked as a |
| 32 | + follow-up issue in the main repo. |
| 33 | + |
| 34 | +== 0. Preflight (read-only, do first) |
| 35 | + |
| 36 | +[source,console] |
| 37 | +---- |
| 38 | +ssh <user>@<SERVER_IP> 'cat /etc/os-release; free -h; df -h /; nproc; ss -tlnp; podman --version' |
| 39 | +---- |
| 40 | + |
| 41 | +Gates — stop and reassess if any fail: |
| 42 | + |
| 43 | +* Ports 80/443 unbound (`ss -tlnp` shows no listener). If something |
| 44 | + else owns them, this plan does not apply as-is. |
| 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. |
| 65 | +* Image pullable anonymously: |
| 66 | ++ |
| 67 | +[source,console] |
| 68 | +---- |
| 69 | +podman manifest inspect ghcr.io/hyperpolymath/echidna:latest |
| 70 | +---- |
| 71 | ++ |
| 72 | +On 401: make the GHCR package public (GitHub → Packages → echidna → |
| 73 | +Package settings → visibility) or use a read-only PAT with |
| 74 | +`podman login ghcr.io` as the deploy user. |
| 75 | + |
| 76 | +== 1. Server preparation (as root) |
| 77 | + |
| 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 |
| 88 | +
|
| 89 | +# Unprivileged deploy user with lingering services |
| 90 | +useradd -m -s /bin/bash deploy |
| 91 | +loginctl enable-linger deploy |
| 92 | +
|
| 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 |
| 96 | +
|
| 97 | +# Destination for the Caddyfile and the Caddy build context |
| 98 | +mkdir -p /opt/echidna && chown deploy:deploy /opt/echidna |
| 99 | +---- |
| 100 | + |
| 101 | +== 2. Install the deploy unit |
| 102 | + |
| 103 | +Create the quadlet directory *before* copying anything into it — as |
| 104 | +the deploy user on the server: |
| 105 | + |
| 106 | +[source,console] |
| 107 | +---- |
| 108 | +mkdir -p ~/.config/containers/systemd |
| 109 | +---- |
| 110 | + |
| 111 | +Then, from your workstation in this directory: |
| 112 | + |
| 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 | +---- |
| 119 | + |
| 120 | +Back on the server, as deploy: |
| 121 | + |
| 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 | +---- |
| 133 | + |
| 134 | +== 3. DNS (before starting Caddy) |
| 135 | + |
| 136 | +Create in the Cloudflare zone for nesy-prover.dev: |
| 137 | + |
| 138 | +[source] |
| 139 | +---- |
| 140 | +A api <SERVER_IP> proxied=false (grey cloud), TTL 300 |
| 141 | +---- |
| 142 | + |
| 143 | +Grey cloud is required at launch so Caddy's Let's Encrypt HTTP-01 |
| 144 | +challenge reaches the server directly. (Optional later hardening: |
| 145 | +orange-cloud + a Cloudflare Origin CA cert in the Caddyfile.) |
| 146 | + |
| 147 | +Wait until `dig +short api.nesy-prover.dev @1.1.1.1` returns the |
| 148 | +server IP before the first Caddy start — ACME then succeeds first try. |
| 149 | + |
| 150 | +== 4. Bring-up and verification |
| 151 | + |
| 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 | +---- |
| 158 | + |
| 159 | +End-to-end, from anywhere: |
| 160 | + |
| 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 | +---- |
| 169 | + |
| 170 | +Controls: |
| 171 | + |
| 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 | +---- |
| 186 | + |
| 187 | +Reboot survival (validates linger + `[Install]` + Restart in one shot): |
| 188 | + |
| 189 | +[source,console] |
| 190 | +---- |
| 191 | +sudo reboot |
| 192 | +# after it returns: |
| 193 | +podman ps && curl -s https://api.nesy-prover.dev/api/health |
| 194 | +---- |
| 195 | + |
| 196 | +== 5. Updates and rollback |
| 197 | + |
| 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