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
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user