orama/core/pkg/turn/cert_reloader.go
anonpenguin23 f8de4af704 feat(sni-router): implement hot-reloading for route configuration
- Add `FileRouteReloader` to watch and atomically update routes from disk
- Refactor `main` to support seamless configuration updates without restarts
- Ensure existing routes are preserved if a reload encounters an error
2026-06-09 09:23:54 +03:00

106 lines
3.3 KiB
Go

package turn
import (
"crypto/tls"
"fmt"
"os"
"sync"
"time"
"go.uber.org/zap"
)
// turnCertReloadInterval is how often the TURNS certificate file is polled for
// changes. TLS cert renewals (Caddy DNS-01 for cdn.<base-domain>) happen on the
// order of weeks, so a minute of detection latency is irrelevant; polling keeps
// the dependency surface minimal (no fsnotify) and is robust across the
// atomic-rename pattern certbot/Caddy use when writing a renewed cert.
const turnCertReloadInterval = 60 * time.Second
// certReloader serves the current TURNS certificate through a tls.Config
// GetCertificate callback and hot-reloads it when the cert file changes on
// disk. This lets a Caddy-renewed certificate be picked up WITHOUT restarting
// the TURN server — a restart would tear down every active relay (~30s RTC
// drop for users mid-call). See plans/platform/04_STEALTH_TURN.md, the
// "cert renewal during cutover" note.
type certReloader struct {
certPath string
keyPath string
logger *zap.Logger
mu sync.RWMutex
cert *tls.Certificate
modTime time.Time
}
// newCertReloader loads the initial cert/key pair. Returns an error if the
// initial load fails — TURNS cannot start without a valid certificate.
func newCertReloader(certPath, keyPath string, logger *zap.Logger) (*certReloader, error) {
r := &certReloader{certPath: certPath, keyPath: keyPath, logger: logger}
if err := r.reload(); err != nil {
return nil, err
}
return r, nil
}
// reload reads the cert/key pair from disk and atomically swaps it in. On
// failure it leaves the previously-loaded certificate in place: a renewal that
// momentarily presents a half-written or mismatched cert/key file must never
// take TURNS down — the old (still-valid) cert keeps serving until the next
// successful reload.
func (r *certReloader) reload() error {
cert, err := tls.LoadX509KeyPair(r.certPath, r.keyPath)
if err != nil {
return fmt.Errorf("load TURNS cert/key (%s): %w", r.certPath, err)
}
var mod time.Time
if fi, statErr := os.Stat(r.certPath); statErr == nil {
mod = fi.ModTime()
}
r.mu.Lock()
r.cert = &cert
r.modTime = mod
r.mu.Unlock()
return nil
}
// GetCertificate is the tls.Config.GetCertificate callback. It always returns
// the most recently loaded certificate, so every new TLS handshake uses the
// current cert without the listener being recreated.
func (r *certReloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
r.mu.RLock()
defer r.mu.RUnlock()
return r.cert, nil
}
// watch polls the cert file's mtime every interval and reloads when it advances.
// Blocks until stop is closed.
func (r *certReloader) watch(interval time.Duration, stop <-chan struct{}) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
fi, err := os.Stat(r.certPath)
if err != nil {
// File briefly absent during an atomic rename — retry next tick.
continue
}
r.mu.RLock()
unchanged := !fi.ModTime().After(r.modTime)
r.mu.RUnlock()
if unchanged {
continue
}
if err := r.reload(); err != nil {
r.logger.Warn("TURNS cert reload failed; keeping previous certificate",
zap.String("cert_path", r.certPath), zap.Error(err))
continue
}
r.logger.Info("TURNS cert hot-reloaded", zap.String("cert_path", r.certPath))
}
}
}