|
| 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 | + (which rolls back automatically when the updated container fails its |
| 24 | + healthcheck). Requires podman >= 4.4. |
| 25 | +* *Rootless*: the API executes user-supplied prover input; run both |
| 26 | + units as an unprivileged `deploy` user with linger. |
| 27 | +* *Caddy over svalinn*: the estate's svalinn/vordr stack is not yet |
| 28 | + CI-proven; Caddy is the low-risk choice for a public endpoint. The |
| 29 | + `container/compose.toml` selur stack remains for local development. |
| 30 | +* *No API auth at launch*: the service is deliberately public but caged |
| 31 | + (rate limit, body cap, memory/pids limits, tmpfs, unpublished port). |
| 32 | + If abuse appears, add a bearer-token matcher in the Caddyfile for |
| 33 | + POST routes. A server-side per-request prover timeout is tracked as a |
| 34 | + follow-up issue in the main repo. |
| 35 | + |
| 36 | +== 0. Preflight (read-only, do first) |
| 37 | + |
| 38 | + ssh <user>@<SERVER_IP> 'cat /etc/os-release; free -h; df -h /; nproc; ss -tlnp; podman --version' |
| 39 | + |
| 40 | +Gates — stop and reassess if any fail: |
| 41 | + |
| 42 | +* Ports 80/443 unbound (`ss -tlnp` shows no listener). If something |
| 43 | + 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). |
| 49 | +* Image pullable anonymously: |
| 50 | ++ |
| 51 | + podman manifest inspect ghcr.io/hyperpolymath/echidna:latest |
| 52 | ++ |
| 53 | +On 401: make the GHCR package public (GitHub → Packages → echidna → |
| 54 | +Package settings → visibility) or use a read-only PAT with |
| 55 | +`podman login ghcr.io` as the deploy user. |
| 56 | + |
| 57 | +== 1. Server preparation (as root) |
| 58 | + |
| 59 | + apt update && apt upgrade -y |
| 60 | + apt install -y podman unattended-upgrades |
| 61 | + dpkg-reconfigure -f noninteractive unattended-upgrades |
| 62 | + |
| 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 |
| 68 | + |
| 69 | + # Unprivileged deploy user with lingering services |
| 70 | + useradd -m -s /bin/bash deploy |
| 71 | + loginctl enable-linger deploy |
| 72 | + |
| 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 |
| 76 | + |
| 77 | +== 2. Install the deploy unit (as deploy) |
| 78 | + |
| 79 | +From your workstation, from this directory: |
| 80 | + |
| 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/ |
| 84 | + |
| 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.) |
| 87 | + |
| 88 | +On the server, as deploy: |
| 89 | + |
| 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 |
| 95 | + |
| 96 | + systemctl --user daemon-reload |
| 97 | + systemctl --user list-unit-files | grep -E 'echidna|caddy' |
| 98 | + |
| 99 | +== 3. DNS (before starting Caddy) |
| 100 | + |
| 101 | +Create in the Cloudflare zone for nesy-prover.dev: |
| 102 | + |
| 103 | + A api <SERVER_IP> proxied=false (grey cloud), TTL 300 |
| 104 | + |
| 105 | +Grey cloud is required at launch so Caddy's Let's Encrypt HTTP-01 |
| 106 | +challenge reaches the server directly. (Optional later hardening: |
| 107 | +orange-cloud + a Cloudflare Origin CA cert in the Caddyfile.) |
| 108 | + |
| 109 | +Wait until `dig +short api.nesy-prover.dev @1.1.1.1` returns the |
| 110 | +server IP before the first Caddy start — ACME then succeeds first try. |
| 111 | + |
| 112 | +== 4. Bring-up and verification |
| 113 | + |
| 114 | + systemctl --user start echidna caddy |
| 115 | + podman ps # both Up, echidna (healthy) |
| 116 | + journalctl --user -u caddy -f # watch certificate issuance |
| 117 | + |
| 118 | +End-to-end, from anywhere: |
| 119 | + |
| 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)"}' |
| 125 | + |
| 126 | +Controls: |
| 127 | + |
| 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)" |
| 135 | + |
| 136 | +Reboot survival (validates linger + [Install] + Restart in one shot): |
| 137 | + |
| 138 | + sudo reboot |
| 139 | + # after it returns: |
| 140 | + podman ps && curl -s https://api.nesy-prover.dev/api/health |
| 141 | + |
| 142 | +== 5. Updates and rollback |
| 143 | + |
| 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 |
0 commit comments