This directory is the complete deploy unit for running the public ECHIDNA API on a single Hetzner server behind Caddy (auto-TLS via Let’s Encrypt), orchestrated with podman systemd quadlets.
|
|
|
|
-
Quadlets over podman-compose: systemd-native restart-on-boot with no extra daemon, unit-level dependency ordering, and first-class
AutoUpdate=registryintegration withpodman-auto-update.timer. -
Rootless: the API executes user-supplied prover input; run both units as an unprivileged
deployuser with linger. -
Caddy over svalinn: the estate’s svalinn/vordr stack is not yet CI-proven; Caddy is the low-risk choice for a public endpoint. The
container/compose.tomlselur stack remains for local development. -
No API auth at launch: the service is deliberately public but caged (rate limit, body cap, memory/pids limits, tmpfs, unpublished port). If abuse appears, add a bearer-token matcher in the Caddyfile for POST routes. A server-side per-request prover timeout is tracked as a follow-up issue in the main repo. Superseded 2026-11-05: see Containment stopgap (owner ruling 2026-11-05).
Until bearer authentication lands on every execution route, this host serves read-only routes only, and it no longer rolls out new images by itself.
-
The
Caddyfileforwards onlyGET/HEADon/api/healthand/api/provers. Every other request is answered403by Caddy and never reaches the container. That includes/api/prove,/api/verify*,/api/session/,/api/agent/and/api/search. -
echidna.containerdropsAutoUpdate=registryand setsPull=never. The unit runs the image already on the host, so a newly published:latestis not deployed. This freezes the running image. It does not verify it.
Apply it on the host as the deploy user with stopgap-apply.sh, from a
copy of this directory:
ssh deploy@<SERVER_IP> 'mkdir -p /opt/echidna/stopgap'
scp Caddyfile echidna.container stopgap-apply.sh deploy@<SERVER_IP>:/opt/echidna/stopgap/
ssh deploy@<SERVER_IP> 'bash /opt/echidna/stopgap/stopgap-apply.sh apply /opt/echidna/stopgap'The script fails closed. It stops before restarting anything when a step
cannot be confirmed, and it ends by printing stopgap: PASS. In order, it:
-
Checks that
echidna.containeris the stopgap version. It reads the directives as systemd does: indentation and spaces around=are ignored, and so are#and;comment lines. The[Container]section must set exactly oneImage=ghcr.io/hyperpolymath/echidna:latestand exactly onePull=never. NoAutoUpdate=may appear in any section, and noio.containers.autoupdatelabel either. -
Checks that the
Caddyfilehas the stopgap shape. Comments are ignored. There must be exactly one@publicmatcher, holding exactlymethod GET HEADandpath /api/health /api/provers, and no other named matcher. There must be exactly onereverse_proxy, and it must be the only thing insidehandle @public. Exactly one catch-allhandle, next to it, must hold onlyrespond … 403. No otherhandleis allowed, and neither isimport,invoke,handle_path,rewrite,uri,php_fastcgiorforward_auth.methodis allowed only inside@public. This is a check of the text, not ofcaddy adaptoutput: the proxy image carries therate_limitplugin, which a stockcaddydoes not know, and the host has nocaddyoutside that image. -
Detects the install layout, and refuses unless exactly one is present. A quadlet host (§2) runs
echidna.serviceandcaddy.service; an appendix host runscontainer-echidnaandcontainer-caddy. -
Disables
podman-auto-update.timer. That does not end a run the timer has already started, so it also stopspodman-auto-update.service. It then refuses unlesssystemctl --user is-activereports that serviceinactiveorfailed. -
Records the image ID the
echidnacontainer is running, in~/.local/state/echidna/stopgap-running-image. If the local:latesttag has moved since the container started, it points the tag back at the running image, because a restart underPull=neverstarts whatever:latestresolves to. -
Installs the
Caddyfileinto/opt/echidna/. On a quadlet host it also installsechidna.container, reloads systemd and restartsechidnaandcaddy. On an appendix host it restarts onlycontainer-caddy: those containers carry no auto-update label, andpodman generate systemd --newunits do not pull an image that is already present. -
Verifies that the container still runs the recorded image, that the timer is disabled and no auto-update run is active, and that
GET /api/healthanswers200. It then sends each of these and requires403from every one:POSTto/api/prove,/api/verify,/api/verify_raw,/api/verify_parallel,/api/suggest,/api/session/createand/api/agent/plan,GET /api/search,POST /api/health, andGETon a route the server does not have. Any other answer, a429from the rate limiter included, fails the run. One run sends at least 11 requests, and more while it waits for/api/health. The limit is 30 a minute for each client, so wait a minute before runningverifyagain after a failure.
To check the host again later, from any shell:
bash /opt/echidna/stopgap/stopgap-apply.sh verifyDo not proceed if it prints a FAIL line. The script’s behaviour is
tested with stub commands in tests/deploy/stopgap-apply.test.sh. Neither
the script nor its test has been run against the live host.
The stopgap ends in the change that enables authentication. That change
re-pins Image= to the signed image by digest and removes the @public
matcher. Do not re-enable podman-auto-update.timer before then.
ssh <user>@<SERVER_IP> 'cat /etc/os-release; free -h; df -h /; nproc; ss -tlnp; podman --version'Gates — stop and reassess if any fail:
-
Ports 80/443 unbound (
ss -tlnpshows no listener). If something else owns them, this plan does not apply as-is. -
At least 2 GB RAM. The API container is capped at 1500 MB (
PodmanArgs=--memory=1500m), deliberately below the host total so Caddy and the OS keep headroom; raise both together if you want more. The:fullimage (Isabelle/Agda/Julia) needs >= 4 GB and is not deployed here. -
Quadlet keys are all understood by the installed podman. Do not trust a version number for this — measure it. Copy the three unit files to the server and run the generator in dry-run mode:
scp echidna.network echidna.container caddy.container <user>@<SERVER_IP>:/tmp/qcheck/ ssh <user>@<SERVER_IP> 'QUADLET_UNIT_DIRS=/tmp/qcheck /usr/libexec/podman/quadlet -dryrun'
It must print generated
[Service]sections for all three units with no warnings. These units were authored and dry-run verified against podman 5.4; podman 5.x is recommended and 4.x is likely to reject at least one key (HealthOnFailure=in particular). If the dry-run complains, use the Debian-12 fallback in the appendix instead. -
Image pullable anonymously:
podman manifest inspect ghcr.io/hyperpolymath/echidna:latestOn 401: make the GHCR package public (GitHub → Packages → echidna → Package settings → visibility) or use a read-only PAT with
podman login ghcr.ioas the deploy user.
apt update && apt upgrade -y
apt install -y podman unattended-upgrades ufw
dpkg-reconfigure -f noninteractive unattended-upgrades
# Firewall: SSH + HTTP(S) only
ufw default deny incoming
ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp
ufw enable
# Unprivileged deploy user with lingering services
useradd -m -s /bin/bash deploy
loginctl enable-linger deploy
# Let rootless podman bind 80/443
echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/50-unprivileged-ports.conf
sysctl --system
# Destination for the Caddyfile and the Caddy build context
mkdir -p /opt/echidna && chown deploy:deploy /opt/echidnaCreate the quadlet directory before copying anything into it — as the deploy user on the server:
mkdir -p ~/.config/containers/systemdThen, from your workstation in this directory:
scp Caddyfile Containerfile.caddy deploy@<SERVER_IP>:/opt/echidna/
scp echidna.network echidna.container caddy.container \
deploy@<SERVER_IP>:~/.config/containers/systemd/Back on the server, as deploy:
cd /opt/echidna
podman build -f Containerfile.caddy -t localhost/caddy-rl:2 .
podman pull ghcr.io/hyperpolymath/echidna:latest
podman image inspect --format '{{.Digest}}' ghcr.io/hyperpolymath/echidna:latest
# ^ record this digest in env.example / your notes for rollback
systemctl --user daemon-reload
systemctl --user list-unit-files | grep -E 'echidna|caddy'Create in the Cloudflare zone for nesy-prover.dev:
A api <SERVER_IP> proxied=false (grey cloud), TTL 300Grey cloud is required at launch so Caddy’s Let’s Encrypt HTTP-01 challenge reaches the server directly. (Optional later hardening: orange-cloud + a Cloudflare Origin CA cert in the Caddyfile.)
Wait until dig +short api.nesy-prover.dev @1.1.1.1 returns the
server IP before the first Caddy start — ACME then succeeds first try.
systemctl --user start echidna caddy
podman ps # both Up, echidna (healthy)
journalctl --user -u caddy -f # watch certificate issuanceEnd-to-end, from anywhere:
curl -s https://api.nesy-prover.dev/api/health # {"status":"ok",...}
curl -s https://api.nesy-prover.dev/api/provers | head
# While the containment stopgap is in force, execution routes answer 403
# from Caddy. That is the correct result, not a failed deployment:
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://api.nesy-prover.dev/api/verify \
-H 'content-type: application/json' \
-d '{"prover":"Z3","content":"(assert (forall ((x Int)) (= (+ x 0) x)))(check-sat)"}' # expect 403Controls:
# Rate limit: burst 40 requests, expect HTTP 429 after ~30
for i in $(seq 1 40); do curl -s -o /dev/null -w '%{http_code} ' \
https://api.nesy-prover.dev/api/health; done; echo
# Headers: HSTS present, Server header absent
curl -sI https://api.nesy-prover.dev/api/health
# Isolation: the API port must NOT be reachable directly. This probe is
# deliberately plaintext against the raw port — it is asserting that
# nothing answers there, and must fail.
curl -m 5 http://<SERVER_IP>:8081/api/health && echo "REACHABLE - FIX THIS" || echo "unreachable (correct)"Reboot survival (validates linger + [Install] + Restart in one shot):
sudo reboot
# after it returns:
podman ps && curl -s https://api.nesy-prover.dev/api/health|
Note
|
Unattended rollout is disabled while the
Containment stopgap (owner ruling 2026-11-05) is in force. The commands below apply only to a
tag-based Image= with AutoUpdate=registry, because podman
auto-update acts only on containers that carry that label. A
digest-pinned image, which is what the change ending the stopgap
introduces, changes only through a PR that bumps the digest.
|
ghcr-publish.yml pushes a new :latest on every GitHub release.
Enable unattended rollout:
systemctl --user enable --now podman-auto-update.timer
podman auto-update --dry-run # see what would change
podman auto-update # roll out immediatelyHow the safety net actually works: podman auto-update pulls the new
image and restarts the unit. HealthOnFailure=kill in
echidna.container means a container that goes unhealthy is killed,
which fails the systemd unit — and auto-update rolls the unit back to
the image it was running before when the restarted unit does not come
up healthy. Without that key an unhealthy-but-running container would
satisfy systemd and no rollback would occur, so do not remove it.
Manual rollback to a recorded digest. Stop the timer first —
otherwise its next run pulls registry :latest again and silently
undoes the rollback:
systemctl --user disable --now podman-auto-update.timer
podman pull ghcr.io/hyperpolymath/echidna@<DIGEST>
podman tag ghcr.io/hyperpolymath/echidna@<DIGEST> ghcr.io/hyperpolymath/echidna:latest
systemctl --user restart echidnaRe-enabling the timer does nothing on its own: podman auto-update acts
only on containers labelled by AutoUpdate=registry, and the stopgap
unit removes that key. Restoring unattended rollout therefore means
putting AutoUpdate=registry back in echidna.container (and dropping
Pull=never), reloading systemd, and only then enabling the timer. That
ends the containment stopgap, so it belongs to the change that lands
authentication, not to an operator step. The intended end state is pinned
mode: Image= set to the signed image’s digest, no AutoUpdate, and
updates by PR.
podman network create echidna-net
podman create --name echidna --network echidna-net \
--memory=1500m --pids-limit=512 --tmpfs /tmp:rw,size=256m \
--security-opt no-new-privileges \
--health-cmd '/app/bin/echidna --version' --health-interval 30s \
--health-on-failure kill \
ghcr.io/hyperpolymath/echidna:latest server --port 8081 --host 0.0.0.0 --cors
podman create --name caddy --network echidna-net \
-p 80:80 -p 443:443 \
-v /opt/echidna/Caddyfile:/etc/caddy/Caddyfile:ro \
-v caddy-data:/data -v caddy-config:/config \
localhost/caddy-rl:2
podman generate systemd --new --files --name echidna
podman generate systemd --new --files --name caddy
mkdir -p ~/.config/systemd/user && mv container-*.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now container-echidna container-caddy