admin: terminate TLS in the binary, with a certificate that reloads itself
server-test / test (push) Successful in 33s

Direct rather than behind Caddy or nginx. This binary already serves TLS for the
control plane, so it is reuse rather than new machinery; one process with one
config file is most of what makes this thing pleasant to run; and a proxy on the
box would invite someone to eventually front the control plane too, which would
break SPKI pinning because clients pin that certificate's key.

The hard part of TLS is not termination, it is renewal - so the certificate is
re-read when the files change. No reload hook to write, and none to quietly stop
working months later and be noticed only after the certificate has expired. A
torn write (renewal tools write cert and key separately) keeps the previous
certificate rather than taking the listener down.

Not applied to the control plane, on purpose: clients pin that key, so replacing
it should cost an operator a moment's thought and a restart, not happen because
a file changed. Two listeners, two different right answers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-01 17:51:58 +02:00
co-authored by Claude Fable 5
parent cd187f9ef5
commit 6afcb131ef
4 changed files with 302 additions and 1 deletions
+38
View File
@@ -146,3 +146,41 @@ CI (`.gitea/workflows/build-server.yml`): tests on every push touching `server/`
tagging `server-v1.2.3` builds + pushes the container image to the Gitea registry and
attaches static linux amd64/arm64 binaries (+ SHA256SUMS) to a release — the same
artifacts `--self-update` consumes.
## TLS for the admin UI
The binary terminates TLS itself; there is no reverse proxy in the design. It already serves TLS
for the control plane, so this is reuse rather than new machinery, and it keeps the "one process,
one config file" property. A proxy would also invite someone to eventually front the control plane
too — which would break SPKI pinning, because clients pin *that* certificate's key.
```
ECHOLOT_ADMIN_LISTEN=[2001:db8::2]:443
ECHOLOT_ADMIN_TLS_CERT=/etc/echolot/admin.pem
ECHOLOT_ADMIN_TLS_KEY=/etc/echolot/admin.key
ECHOLOT_ADMIN_BASE_URL=https://admin.example.net
```
Certificates come from any ACME client. **DNS-01 is the one to use here**: it needs no inbound
port 80, which matters on a host where 80 is awkward or already spoken for.
```sh
acme.sh --issue --dns dns_cf -d admin.example.net \
--key-file /etc/echolot/admin.key \
--fullchain-file /etc/echolot/admin.pem
```
**No reload hook is needed.** The certificate is re-read when the files change, so a renewal that
drops new files in place is picked up on the next handshake. That is deliberate: a reload hook is
the part of a renewal setup that quietly stops working, months later, and is noticed only once the
certificate has already expired. A torn write — renewal tools write cert and key separately — keeps
the previous certificate rather than failing the listener.
Serving the admin UI in plaintext on a non-loopback address is refused: the session cookie is a
bearer credential for everything the server can do, and the OIDC authorization code arrives in a
URL. Bind to loopback and use an SSH tunnel (`ssh -L 8444:localhost:8444 host`), supply a
certificate, or set `ECHOLOT_ADMIN_INSECURE=1` if you mean it.
The control-plane certificate is deliberately *not* hot-reloaded. Clients pin its public key, so
replacing it is a rotation an operator should have to think about, not something that happens
because a file changed.