// 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() }