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.) 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)) } } }