admin: terminate TLS in the binary, with a certificate that reloads itself
server-test / test (push) Successful in 33s
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:
co-authored by
Claude Fable 5
parent
cd187f9ef5
commit
6afcb131ef
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user