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
+124
View File
@@ -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()
}