package turn import ( "crypto/hmac" "crypto/sha1" "crypto/tls" "encoding/base64" "fmt" "net" "strconv" "strings" "time" pionTurn "github.com/pion/turn/v4" "go.uber.org/zap" ) // stealthConfigFieldCount is the number of stealth TLS config fields that must // be set together (StealthDomain, TLSStealthCertPath, TLSStealthKeyPath). Any // other count is a partial config and fails server startup. const stealthConfigFieldCount = 3 // Server wraps a Pion TURN server with namespace-scoped HMAC-SHA1 authentication. type Server struct { config *Config logger *zap.Logger turnServer *pionTurn.Server conn net.PacketConn // UDP listener on primary port (3478) tcpListener net.Listener // Plain TCP listener on primary port (3478) tlsListener net.Listener // TLS TCP listener for TURNS (port 5349) certReloader *certReloader // hot-reloads the primary TURNS cert; nil when TURNS disabled stealthCertReloader *certReloader // hot-reloads the stealth-SNI cert; nil when stealth disabled certStop chan struct{} // closed to stop the cert-reload watcher goroutine(s) } // NewServer creates and starts a TURN server. func NewServer(cfg *Config, logger *zap.Logger) (*Server, error) { if errs := cfg.Validate(); len(errs) > 0 { return nil, fmt.Errorf("invalid TURN config: %v", errs[0]) } relayIP := net.ParseIP(cfg.PublicIP) if relayIP == nil { return nil, fmt.Errorf("turn.public_ip: %q is not a valid IP address", cfg.PublicIP) } s := &Server{ config: cfg, logger: logger.With(zap.String("component", "turn"), zap.String("namespace", cfg.Namespace)), } // Create primary UDP listener (port 3478) conn, err := net.ListenPacket("udp4", cfg.ListenAddr) if err != nil { return nil, fmt.Errorf("failed to listen on %s: %w", cfg.ListenAddr, err) } s.conn = conn packetConfigs := []pionTurn.PacketConnConfig{ { PacketConn: conn, RelayAddressGenerator: &pionTurn.RelayAddressGeneratorPortRange{ RelayAddress: relayIP, Address: "0.0.0.0", MinPort: uint16(cfg.RelayPortStart), MaxPort: uint16(cfg.RelayPortEnd), }, }, } // Plain TCP listener on same port as UDP (3478) for TCP TURN fallback var listenerConfigs []pionTurn.ListenerConfig tcpListener, err := net.Listen("tcp", cfg.ListenAddr) if err != nil { conn.Close() return nil, fmt.Errorf("failed to listen TCP on %s: %w", cfg.ListenAddr, err) } s.tcpListener = tcpListener listenerConfigs = append(listenerConfigs, pionTurn.ListenerConfig{ Listener: tcpListener, RelayAddressGenerator: &pionTurn.RelayAddressGeneratorPortRange{ RelayAddress: relayIP, Address: "0.0.0.0", MinPort: uint16(cfg.RelayPortStart), MaxPort: uint16(cfg.RelayPortEnd), }, }) // TURNS: TLS over TCP listener (port 5349) if configured. // // The cert is served via a hot-reloading GetCertificate callback rather // than a static Certificates slice, so a Caddy-renewed cert is picked up // in-process without restarting TURN (a restart drops every active relay // ~30s). See certReloader / plans/platform/04_STEALTH_TURN.md. if cfg.TURNSListenAddr != "" && cfg.TLSCertPath != "" && cfg.TLSKeyPath != "" { reloader, err := newCertReloader(cfg.TLSCertPath, cfg.TLSKeyPath, s.logger) if err != nil { s.closeListeners() return nil, fmt.Errorf("failed to load TLS cert/key: %w", err) } s.certReloader = reloader // Stealth SNI: when configured, terminate TLS for a second (neutral) // hostname using its own hot-reloading cert. The SNI router forwards the // raw stealth-domain bytes to this listener; selection is by ServerName. if err := s.loadStealthCertReloader(cfg); err != nil { s.closeListeners() return nil, err } tlsConfig := &tls.Config{ GetCertificate: newGetCertificate(cfg.StealthDomain, reloader, s.stealthCertReloader), MinVersion: tls.VersionTLS12, } tlsListener, err := tls.Listen("tcp", cfg.TURNSListenAddr, tlsConfig) if err != nil { s.closeListeners() return nil, fmt.Errorf("failed to listen on %s: %w", cfg.TURNSListenAddr, err) } s.tlsListener = tlsListener s.certStop = make(chan struct{}) go reloader.watch(turnCertReloadInterval, s.certStop) if s.stealthCertReloader != nil { go s.stealthCertReloader.watch(turnCertReloadInterval, s.certStop) } listenerConfigs = append(listenerConfigs, pionTurn.ListenerConfig{ Listener: tlsListener, RelayAddressGenerator: &pionTurn.RelayAddressGeneratorPortRange{ RelayAddress: relayIP, Address: "0.0.0.0", MinPort: uint16(cfg.RelayPortStart), MaxPort: uint16(cfg.RelayPortEnd), }, }) } // Create TURN server with HMAC-SHA1 auth serverConfig := pionTurn.ServerConfig{ Realm: cfg.Realm, AuthHandler: func(username, realm string, srcAddr net.Addr) ([]byte, bool) { return s.authHandler(username, realm, srcAddr) }, PacketConnConfigs: packetConfigs, } if len(listenerConfigs) > 0 { serverConfig.ListenerConfigs = listenerConfigs } turnServer, err := pionTurn.NewServer(serverConfig) if err != nil { s.closeListeners() return nil, fmt.Errorf("failed to create TURN server: %w", err) } s.turnServer = turnServer s.logger.Info("TURN server started", zap.String("listen_addr_udp", cfg.ListenAddr), zap.String("listen_addr_tcp", cfg.ListenAddr), zap.String("turns_listen_addr", cfg.TURNSListenAddr), zap.String("public_ip", cfg.PublicIP), zap.String("realm", cfg.Realm), zap.Int("relay_port_start", cfg.RelayPortStart), zap.Int("relay_port_end", cfg.RelayPortEnd), ) return s, nil } // loadStealthCertReloader sets up the second cert reloader used for the stealth // SNI hostname, storing it on s.stealthCertReloader. The three stealth fields // (StealthDomain, TLSStealthCertPath, TLSStealthKeyPath) are all-or-nothing: a // partial config is an operator mistake and fails startup rather than silently // running without the stealth endpoint. When none are set, stealth is disabled // and the primary TLS path is byte-for-byte unchanged. func (s *Server) loadStealthCertReloader(cfg *Config) error { set := 0 if cfg.StealthDomain != "" { set++ } if cfg.TLSStealthCertPath != "" { set++ } if cfg.TLSStealthKeyPath != "" { set++ } if set == 0 { return nil // stealth disabled } if set != stealthConfigFieldCount { var missing []string if cfg.StealthDomain == "" { missing = append(missing, "stealth_domain") } if cfg.TLSStealthCertPath == "" { missing = append(missing, "tls_stealth_cert_path") } if cfg.TLSStealthKeyPath == "" { missing = append(missing, "tls_stealth_key_path") } return fmt.Errorf("turn: partial stealth config — set all of [stealth_domain, tls_stealth_cert_path, tls_stealth_key_path] or none; missing: %s", strings.Join(missing, ", ")) } reloader, err := newCertReloader(cfg.TLSStealthCertPath, cfg.TLSStealthKeyPath, s.logger) if err != nil { return fmt.Errorf("failed to load stealth TLS cert/key (cert=%s, key=%s): %w", cfg.TLSStealthCertPath, cfg.TLSStealthKeyPath, err) } s.stealthCertReloader = reloader return nil } // newGetCertificate builds the tls.Config.GetCertificate callback. When the // ClientHello ServerName equals stealthDomain (case-insensitively), it serves // the stealth cert; every other case — including empty SNI and the primary TURN // domain — serves the primary cert, preserving the pre-stealth behavior. When // stealth is disabled (stealthReloader nil) it is exactly primary.GetCertificate. func newGetCertificate(stealthDomain string, primary, stealth *certReloader) func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { if stealth != nil && hello != nil && strings.EqualFold(hello.ServerName, stealthDomain) { return stealth.GetCertificate(hello) } return primary.GetCertificate(hello) } } // authHandler validates HMAC-SHA1 credentials. // Username format: {expiry_unix}:{namespace} // Password: base64(HMAC-SHA1(shared_secret, username)) func (s *Server) authHandler(username, realm string, srcAddr net.Addr) ([]byte, bool) { // Parse username: must be "{timestamp}:{namespace}" parts := strings.SplitN(username, ":", 2) if len(parts) != 2 { s.logger.Debug("Malformed TURN username: expected timestamp:namespace", zap.String("username", username), zap.String("src_addr", srcAddr.String())) return nil, false } timestamp, err := strconv.ParseInt(parts[0], 10, 64) if err != nil { s.logger.Debug("Invalid timestamp in TURN username", zap.String("username", username), zap.String("src_addr", srcAddr.String())) return nil, false } ns := parts[1] // Verify namespace matches this TURN server's namespace if ns != s.config.Namespace { s.logger.Debug("TURN credential namespace mismatch", zap.String("credential_namespace", ns), zap.String("server_namespace", s.config.Namespace), zap.String("src_addr", srcAddr.String())) return nil, false } // Check expiry — credential must not be expired if timestamp <= time.Now().Unix() { s.logger.Debug("TURN credential expired", zap.String("username", username), zap.Int64("expired_at", timestamp), zap.String("src_addr", srcAddr.String())) return nil, false } // Generate expected password and derive auth key password := GeneratePassword(s.config.AuthSecret, username) key := pionTurn.GenerateAuthKey(username, realm, password) s.logger.Debug("TURN auth accepted", zap.String("namespace", ns), zap.String("src_addr", srcAddr.String())) return key, true } // Close gracefully shuts down the TURN server. func (s *Server) Close() error { s.logger.Info("Stopping TURN server") if s.turnServer != nil { if err := s.turnServer.Close(); err != nil { s.logger.Warn("Error closing TURN server", zap.Error(err)) } } s.closeListeners() s.logger.Info("TURN server stopped") return nil } // closeListeners stops the cert watcher and closes all listeners. It is // idempotent (every field is nil-guarded and nil'd after use) but is NOT // mutex-protected — it relies on its call sites being single-threaded relative // to each other (sequential construction, plus a single Close() from main). func (s *Server) closeListeners() { if s.certStop != nil { close(s.certStop) s.certStop = nil } if s.conn != nil { s.conn.Close() s.conn = nil } if s.tcpListener != nil { s.tcpListener.Close() s.tcpListener = nil } if s.tlsListener != nil { s.tlsListener.Close() s.tlsListener = nil } s.certReloader = nil s.stealthCertReloader = nil } // GenerateCredentials creates time-limited HMAC-SHA1 TURN credentials. // Returns username and password suitable for WebRTC ICE server configuration. func GenerateCredentials(secret, namespace string, ttl time.Duration) (username, password string) { expiry := time.Now().Add(ttl).Unix() username = fmt.Sprintf("%d:%s", expiry, namespace) password = GeneratePassword(secret, username) return username, password } // GeneratePassword computes the HMAC-SHA1 password for a TURN username. func GeneratePassword(secret, username string) string { h := hmac.New(sha1.New, []byte(secret)) h.Write([]byte(username)) return base64.StdEncoding.EncodeToString(h.Sum(nil)) } // ValidateCredentials checks if TURN credentials are valid and not expired. func ValidateCredentials(secret, username, password, expectedNamespace string) bool { parts := strings.SplitN(username, ":", 2) if len(parts) != 2 { return false } timestamp, err := strconv.ParseInt(parts[0], 10, 64) if err != nil { return false } // Check namespace if parts[1] != expectedNamespace { return false } // Check expiry if timestamp <= time.Now().Unix() { return false } // Check password expected := GeneratePassword(secret, username) return hmac.Equal([]byte(password), []byte(expected)) }