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
+21 -1
View File
@@ -39,6 +39,7 @@ import (
"echo-lot.app/server/internal/adminauth"
"echo-lot.app/server/internal/canarydns"
"echo-lot.app/server/internal/certreload"
"echo-lot.app/server/internal/compat"
"echo-lot.app/server/internal/config"
"echo-lot.app/server/internal/control"
@@ -314,7 +315,26 @@ func serve(cfg *config.Config) error {
})
})
adminSrv := &http.Server{Addr: cfg.AdminListen, Handler: admin, ReadHeaderTimeout: 10 * time.Second}
go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServe()) }()
if cfg.AdminTLSCert != "" {
// Terminated here rather than behind a reverse proxy: this binary already serves TLS for
// the control plane, so it is reuse rather than new machinery, and one process with one
// config file is the property that makes this pleasant to run. A proxy would also invite
// someone to later front the control plane too, which would break SPKI pinning.
reloader, err := certreload.New(cfg.AdminTLSCert, cfg.AdminTLSKey)
if err != nil {
return fmt.Errorf("admin TLS: %w", err)
}
adminSrv.TLSConfig = reloader.TLSConfig()
if exp := reloader.NotAfter(); !exp.IsZero() {
slog.Info("admin UI TLS", "listen", cfg.AdminListen, "cert_expires", exp.Format(time.RFC3339))
if time.Until(exp) < 14*24*time.Hour {
slog.Warn("admin certificate expires soon", "expires", exp.Format(time.RFC3339))
}
}
go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServeTLS("", "")) }()
} else {
go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServe()) }()
}
// UDP data plane — one socket per configured address. Distinct sockets
// (not wildcard) also guarantee responses leave from the address the