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
|
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
|
attaches static linux amd64/arm64 binaries (+ SHA256SUMS) to a release — the same
|
||||||
artifacts `--self-update` consumes.
|
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.
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import (
|
|||||||
|
|
||||||
"echo-lot.app/server/internal/adminauth"
|
"echo-lot.app/server/internal/adminauth"
|
||||||
"echo-lot.app/server/internal/canarydns"
|
"echo-lot.app/server/internal/canarydns"
|
||||||
|
"echo-lot.app/server/internal/certreload"
|
||||||
"echo-lot.app/server/internal/compat"
|
"echo-lot.app/server/internal/compat"
|
||||||
"echo-lot.app/server/internal/config"
|
"echo-lot.app/server/internal/config"
|
||||||
"echo-lot.app/server/internal/control"
|
"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}
|
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
|
// UDP data plane — one socket per configured address. Distinct sockets
|
||||||
// (not wildcard) also guarantee responses leave from the address the
|
// (not wildcard) also guarantee responses leave from the address the
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// Package certreload serves a TLS certificate that can be replaced on disk without a restart.
|
||||||
|
//
|
||||||
|
// The hard part of TLS is never termination — the stdlib does that — it is renewal. A certificate
|
||||||
|
// obtained from an ACME client expires every sixty days, and the usual arrangement is a renewal
|
||||||
|
// hook that reloads or restarts the service. That hook is the part that quietly fails: it works
|
||||||
|
// when it is written and then, months later, does not, and nobody notices until the certificate
|
||||||
|
// has already expired.
|
||||||
|
//
|
||||||
|
// So the certificate is re-read when the file changes. There is no hook to forget, no reload to
|
||||||
|
// coordinate, and a renewal that drops new files in place is picked up on the next handshake.
|
||||||
|
//
|
||||||
|
// Deliberately not used for the control plane. Clients pin that certificate's public key
|
||||||
|
// (probe-protocol.md §1), so swapping it at runtime would silently break every enrolled device —
|
||||||
|
// there the operator *should* have to think, and a restart is the least of what a key rotation
|
||||||
|
// costs. Two listeners, two different right answers.
|
||||||
|
package certreload
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reloader holds a certificate and refreshes it when the files on disk change.
|
||||||
|
type Reloader struct {
|
||||||
|
certPath, keyPath string
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
cert *tls.Certificate
|
||||||
|
certMod time.Time
|
||||||
|
keyMod time.Time
|
||||||
|
checked time.Time
|
||||||
|
interval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// New loads the pair once so a bad path fails at startup rather than at the first handshake,
|
||||||
|
// when the only symptom is a connection error at the far end.
|
||||||
|
func New(certPath, keyPath string) (*Reloader, error) {
|
||||||
|
r := &Reloader{certPath: certPath, keyPath: keyPath, interval: 30 * time.Second}
|
||||||
|
if err := r.load(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLSConfig returns a config that asks this reloader for the certificate on every handshake.
|
||||||
|
func (r *Reloader) TLSConfig() *tls.Config {
|
||||||
|
return &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
GetCertificate: r.getCertificate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloader) getCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||||
|
r.maybeReload()
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
if r.cert == nil {
|
||||||
|
return nil, fmt.Errorf("no certificate loaded")
|
||||||
|
}
|
||||||
|
return r.cert, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeReload stats the files at most once per interval.
|
||||||
|
//
|
||||||
|
// Rate-limited because this runs on every handshake: a busy listener would otherwise stat twice
|
||||||
|
// per connection, and a certificate that is thirty seconds stale has never mattered to anyone.
|
||||||
|
func (r *Reloader) maybeReload() {
|
||||||
|
r.mu.RLock()
|
||||||
|
fresh := time.Since(r.checked) < r.interval
|
||||||
|
r.mu.RUnlock()
|
||||||
|
if fresh {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
r.checked = time.Now()
|
||||||
|
certMod, keyMod := modTime(r.certPath), modTime(r.keyPath)
|
||||||
|
unchanged := certMod.Equal(r.certMod) && keyMod.Equal(r.keyMod)
|
||||||
|
r.mu.Unlock()
|
||||||
|
if unchanged {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A failed reload keeps the certificate already in memory. Renewal tools write the two files
|
||||||
|
// separately, so there is a window where the pair does not match; serving the previous
|
||||||
|
// certificate through that window is strictly better than serving none.
|
||||||
|
_ = r.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloader) load() error {
|
||||||
|
cert, err := tls.LoadX509KeyPair(r.certPath, r.keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("loading %s / %s: %w", r.certPath, r.keyPath, err)
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.cert = &cert
|
||||||
|
r.certMod, r.keyMod = modTime(r.certPath), modTime(r.keyPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotAfter is when the loaded certificate expires, for the admin UI to show and for a startup
|
||||||
|
// warning. An expiry an operator can see is one they can act on before a browser tells them.
|
||||||
|
func (r *Reloader) NotAfter() time.Time {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
if r.cert == nil || r.cert.Leaf == nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return r.cert.Leaf.NotAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
func modTime(path string) time.Time {
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return fi.ModTime()
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package certreload
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writePair(t *testing.T, dir, cn string) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tmpl := x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||||
|
Subject: pkix.Name{CommonName: cn},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
certPath := filepath.Join(dir, "cert.pem")
|
||||||
|
keyPath := filepath.Join(dir, "key.pem")
|
||||||
|
cb := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||||
|
kb, _ := x509.MarshalECPrivateKey(key)
|
||||||
|
if err := os.WriteFile(certPath, cb, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb}), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return certPath, keyPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBadPathsFailAtStartupNotAtHandshake(t *testing.T) {
|
||||||
|
if _, err := New("/nonexistent/cert.pem", "/nonexistent/key.pem"); err == nil {
|
||||||
|
t.Fatal("a missing certificate was accepted; the failure would surface as an " +
|
||||||
|
"unexplained connection error at the client instead")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point: a renewal that drops new files in place is picked up without a restart and
|
||||||
|
// without a reload hook that can silently stop working.
|
||||||
|
func TestANewCertificateOnDiskIsPickedUp(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
certPath, keyPath := writePair(t, dir, "first")
|
||||||
|
r, err := New(certPath, keyPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r.interval = 0 // check on every handshake, rather than waiting out the rate limit
|
||||||
|
|
||||||
|
got, err := r.getCertificate(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first := got.Leaf
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond) // ensure a distinct mtime
|
||||||
|
writePair(t, dir, "second")
|
||||||
|
|
||||||
|
got, err = r.getCertificate(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Leaf != nil && first != nil && got.Leaf.SerialNumber.Cmp(first.SerialNumber) == 0 {
|
||||||
|
t.Fatal("the replaced certificate was not picked up")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renewal tools write the certificate and the key separately, so there is a window where the two
|
||||||
|
// do not match. Serving the previous certificate through it beats serving none.
|
||||||
|
func TestAHalfWrittenPairKeepsTheOldCertificate(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
certPath, keyPath := writePair(t, dir, "good")
|
||||||
|
r, err := New(certPath, keyPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r.interval = 0
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
if err := os.WriteFile(certPath, []byte("-----BEGIN CERTIFICATE-----\ntruncated\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := r.getCertificate(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a torn write took the listener down: %v", err)
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("no certificate served during a torn write")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryIsVisible(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
certPath, keyPath := writePair(t, dir, "x")
|
||||||
|
r, err := New(certPath, keyPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := r.NotAfter(); got.IsZero() || time.Until(got) > 48*time.Hour {
|
||||||
|
t.Fatalf("expiry not reported sensibly: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user