From 714a986a78eb95fedfb1030131ebf2fdeaf405ec Mon Sep 17 00:00:00 2001 From: anonpenguin23 Date: Mon, 23 Feb 2026 16:32:32 +0200 Subject: [PATCH] Bump version to 0.112.2 and update TURN server configuration - Updated version in Makefile to 0.112.2. - Enhanced SFU server error handling to ignore http.ErrServerClosed. - Added TURNS (TURN over TLS) configuration options in TURN server and related components. - Updated firewall rules to include TURNS ports and modified related tests. - Implemented self-signed certificate generation for TURNS. - Adjusted TURN server to support both UDP and TCP listeners. - Updated WebRTC and SFU components to accommodate new TURNS configurations. --- Makefile | 2 +- cmd/sfu/main.go | 4 +- cmd/turn/config.go | 36 ++++---- pkg/environments/production/firewall.go | 13 +-- pkg/environments/production/firewall_test.go | 15 ++++ .../handlers/namespace/spawn_handler.go | 4 +- pkg/gateway/handlers/webrtc/credentials.go | 3 +- pkg/gateway/handlers/webrtc/handlers_test.go | 4 +- pkg/namespace/cluster_manager.go | 22 ++--- pkg/namespace/cluster_manager_webrtc.go | 33 ++++---- pkg/namespace/dns_manager.go | 14 ++-- pkg/namespace/systemd_spawner.go | 77 +++++++++++++---- pkg/namespace/types.go | 4 +- pkg/namespace/webrtc_port_allocator.go | 2 +- pkg/namespace/webrtc_port_allocator_test.go | 6 +- pkg/sfu/config.go | 5 +- pkg/sfu/room.go | 7 +- pkg/sfu/room_test.go | 18 ++-- pkg/sfu/server.go | 7 +- pkg/sfu/signaling_test.go | 2 +- pkg/turn/config.go | 11 ++- pkg/turn/server.go | 78 ++++++++++++----- pkg/turn/tls.go | 83 +++++++++++++++++++ 23 files changed, 329 insertions(+), 121 deletions(-) create mode 100644 pkg/turn/tls.go diff --git a/Makefile b/Makefile index 131d7dd..e883d95 100644 --- a/Makefile +++ b/Makefile @@ -63,7 +63,7 @@ test-e2e-quick: .PHONY: build clean test deps tidy fmt vet lint install-hooks redeploy-devnet redeploy-testnet release health -VERSION := 0.112.1 +VERSION := 0.112.2 COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.commit=$(COMMIT)' -X 'main.date=$(DATE)' diff --git a/cmd/sfu/main.go b/cmd/sfu/main.go index e71d9c9..60b12ac 100644 --- a/cmd/sfu/main.go +++ b/cmd/sfu/main.go @@ -1,6 +1,8 @@ package main import ( + "errors" + "net/http" "os" "os/signal" "syscall" @@ -35,7 +37,7 @@ func main() { // Start HTTP server in background go func() { - if err := server.ListenAndServe(); err != nil { + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.ComponentError(logging.ComponentSFU, "SFU server error", zap.Error(err)) os.Exit(1) } diff --git a/cmd/turn/config.go b/cmd/turn/config.go index 0b38f81..a302c2b 100644 --- a/cmd/turn/config.go +++ b/cmd/turn/config.go @@ -40,14 +40,16 @@ func parseTURNConfig(logger *logging.ColoredLogger) *turn.Config { } type yamlCfg struct { - ListenAddr string `yaml:"listen_addr"` - TLSListenAddr string `yaml:"tls_listen_addr"` - PublicIP string `yaml:"public_ip"` - Realm string `yaml:"realm"` - AuthSecret string `yaml:"auth_secret"` - RelayPortStart int `yaml:"relay_port_start"` - RelayPortEnd int `yaml:"relay_port_end"` - Namespace string `yaml:"namespace"` + ListenAddr string `yaml:"listen_addr"` + TURNSListenAddr string `yaml:"turns_listen_addr"` + PublicIP string `yaml:"public_ip"` + Realm string `yaml:"realm"` + AuthSecret string `yaml:"auth_secret"` + RelayPortStart int `yaml:"relay_port_start"` + RelayPortEnd int `yaml:"relay_port_end"` + Namespace string `yaml:"namespace"` + TLSCertPath string `yaml:"tls_cert_path"` + TLSKeyPath string `yaml:"tls_key_path"` } data, err := os.ReadFile(configPath) @@ -66,14 +68,16 @@ func parseTURNConfig(logger *logging.ColoredLogger) *turn.Config { } cfg := &turn.Config{ - ListenAddr: y.ListenAddr, - TLSListenAddr: y.TLSListenAddr, - PublicIP: y.PublicIP, - Realm: y.Realm, - AuthSecret: y.AuthSecret, - RelayPortStart: y.RelayPortStart, - RelayPortEnd: y.RelayPortEnd, - Namespace: y.Namespace, + ListenAddr: y.ListenAddr, + TURNSListenAddr: y.TURNSListenAddr, + PublicIP: y.PublicIP, + Realm: y.Realm, + AuthSecret: y.AuthSecret, + RelayPortStart: y.RelayPortStart, + RelayPortEnd: y.RelayPortEnd, + Namespace: y.Namespace, + TLSCertPath: y.TLSCertPath, + TLSKeyPath: y.TLSKeyPath, } if errs := cfg.Validate(); len(errs) > 0 { diff --git a/pkg/environments/production/firewall.go b/pkg/environments/production/firewall.go index 0074a7c..484c345 100644 --- a/pkg/environments/production/firewall.go +++ b/pkg/environments/production/firewall.go @@ -12,7 +12,7 @@ type FirewallConfig struct { IsNameserver bool // enables port 53 TCP+UDP AnyoneORPort int // 0 = disabled, typically 9001 WireGuardPort int // default 51820 - TURNEnabled bool // enables TURN relay ports (3478/udp, 443/udp, relay range) + TURNEnabled bool // enables TURN relay ports (3478/udp+tcp, 5349/tcp, relay range) TURNRelayStart int // start of TURN relay port range (default 49152) TURNRelayEnd int // end of TURN relay port range (default 65535) } @@ -89,8 +89,9 @@ func (fp *FirewallProvisioner) GenerateRules() []string { // TURN relay (only for nodes running TURN servers) if fp.config.TURNEnabled { - rules = append(rules, "ufw allow 3478/udp") // TURN standard port - rules = append(rules, "ufw allow 443/udp") // TURN TLS port (does not conflict with Caddy TCP 443) + rules = append(rules, "ufw allow 3478/udp") // TURN standard port (UDP) + rules = append(rules, "ufw allow 3478/tcp") // TURN standard port (TCP fallback) + rules = append(rules, "ufw allow 5349/tcp") // TURNS (TURN over TLS/TCP) if fp.config.TURNRelayStart > 0 && fp.config.TURNRelayEnd > 0 { rules = append(rules, fmt.Sprintf("ufw allow %d:%d/udp", fp.config.TURNRelayStart, fp.config.TURNRelayEnd)) } @@ -147,7 +148,8 @@ func (fp *FirewallProvisioner) IsActive() bool { func (fp *FirewallProvisioner) AddWebRTCRules(relayStart, relayEnd int) error { rules := []string{ "ufw allow 3478/udp", - "ufw allow 443/udp", + "ufw allow 3478/tcp", + "ufw allow 5349/tcp", } if relayStart > 0 && relayEnd > 0 { rules = append(rules, fmt.Sprintf("ufw allow %d:%d/udp", relayStart, relayEnd)) @@ -168,7 +170,8 @@ func (fp *FirewallProvisioner) AddWebRTCRules(relayStart, relayEnd int) error { func (fp *FirewallProvisioner) RemoveWebRTCRules(relayStart, relayEnd int) error { rules := []string{ "ufw delete allow 3478/udp", - "ufw delete allow 443/udp", + "ufw delete allow 3478/tcp", + "ufw delete allow 5349/tcp", } if relayStart > 0 && relayEnd > 0 { rules = append(rules, fmt.Sprintf("ufw delete allow %d:%d/udp", relayStart, relayEnd)) diff --git a/pkg/environments/production/firewall_test.go b/pkg/environments/production/firewall_test.go index 43690e9..8aaba7f 100644 --- a/pkg/environments/production/firewall_test.go +++ b/pkg/environments/production/firewall_test.go @@ -96,6 +96,21 @@ func TestFirewallProvisioner_GenerateRules_FullConfig(t *testing.T) { assertContainsRule(t, rules, "ufw allow 9001/tcp") } +func TestFirewallProvisioner_GenerateRules_WithTURN(t *testing.T) { + fp := NewFirewallProvisioner(FirewallConfig{ + TURNEnabled: true, + TURNRelayStart: 49152, + TURNRelayEnd: 49951, + }) + + rules := fp.GenerateRules() + + assertContainsRule(t, rules, "ufw allow 3478/udp") + assertContainsRule(t, rules, "ufw allow 3478/tcp") + assertContainsRule(t, rules, "ufw allow 5349/tcp") + assertContainsRule(t, rules, "ufw allow 49152:49951/udp") +} + func TestFirewallProvisioner_DefaultPorts(t *testing.T) { fp := NewFirewallProvisioner(FirewallConfig{}) diff --git a/pkg/gateway/handlers/namespace/spawn_handler.go b/pkg/gateway/handlers/namespace/spawn_handler.go index c070be7..2e0be35 100644 --- a/pkg/gateway/handlers/namespace/spawn_handler.go +++ b/pkg/gateway/handlers/namespace/spawn_handler.go @@ -65,7 +65,7 @@ type SpawnRequest struct { // TURN config (when action = "spawn-turn") TURNListenAddr string `json:"turn_listen_addr,omitempty"` - TURNTLSAddr string `json:"turn_tls_addr,omitempty"` + TURNTURNSAddr string `json:"turn_turns_addr,omitempty"` TURNPublicIP string `json:"turn_public_ip,omitempty"` TURNRealm string `json:"turn_realm,omitempty"` TURNAuthSecret string `json:"turn_auth_secret,omitempty"` @@ -347,7 +347,7 @@ func (h *SpawnHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { Namespace: req.Namespace, NodeID: req.NodeID, ListenAddr: req.TURNListenAddr, - TLSListenAddr: req.TURNTLSAddr, + TURNSListenAddr: req.TURNTURNSAddr, PublicIP: req.TURNPublicIP, Realm: req.TURNRealm, AuthSecret: req.TURNAuthSecret, diff --git a/pkg/gateway/handlers/webrtc/credentials.go b/pkg/gateway/handlers/webrtc/credentials.go index dba6544..405b734 100644 --- a/pkg/gateway/handlers/webrtc/credentials.go +++ b/pkg/gateway/handlers/webrtc/credentials.go @@ -38,7 +38,8 @@ func (h *WebRTCHandlers) CredentialsHandler(w http.ResponseWriter, r *http.Reque if h.turnDomain != "" { uris = append(uris, fmt.Sprintf("turn:%s:3478?transport=udp", h.turnDomain), - fmt.Sprintf("turn:%s:443?transport=udp", h.turnDomain), + fmt.Sprintf("turn:%s:3478?transport=tcp", h.turnDomain), + fmt.Sprintf("turns:%s:5349", h.turnDomain), ) } diff --git a/pkg/gateway/handlers/webrtc/handlers_test.go b/pkg/gateway/handlers/webrtc/handlers_test.go index 0b974bf..1be80c1 100644 --- a/pkg/gateway/handlers/webrtc/handlers_test.go +++ b/pkg/gateway/handlers/webrtc/handlers_test.go @@ -61,8 +61,8 @@ func TestCredentialsHandler_Success(t *testing.T) { t.Errorf("ttl = %v, want 600", result["ttl"]) } uris, ok := result["uris"].([]interface{}) - if !ok || len(uris) != 2 { - t.Errorf("uris count = %v, want 2", result["uris"]) + if !ok || len(uris) != 3 { + t.Errorf("uris count = %v, want 3", result["uris"]) } } diff --git a/pkg/namespace/cluster_manager.go b/pkg/namespace/cluster_manager.go index 11350e4..b5d1aaa 100644 --- a/pkg/namespace/cluster_manager.go +++ b/pkg/namespace/cluster_manager.go @@ -1956,15 +1956,15 @@ func (cm *ClusterManager) restoreClusterFromState(ctx context.Context, state *Cl webrtcCfg, err := cm.GetWebRTCConfig(ctx, state.NamespaceName) if err == nil && webrtcCfg != nil { turnCfg := TURNInstanceConfig{ - Namespace: state.NamespaceName, - NodeID: cm.localNodeID, - ListenAddr: fmt.Sprintf("0.0.0.0:%d", state.TURNListenPort), - TLSListenAddr: fmt.Sprintf("0.0.0.0:%d", state.TURNTLSPort), - PublicIP: "", // Will be resolved by spawner or from node info - Realm: cm.baseDomain, - AuthSecret: webrtcCfg.TURNSharedSecret, - RelayPortStart: state.TURNRelayPortStart, - RelayPortEnd: state.TURNRelayPortEnd, + Namespace: state.NamespaceName, + NodeID: cm.localNodeID, + ListenAddr: fmt.Sprintf("0.0.0.0:%d", state.TURNListenPort), + TURNSListenAddr: fmt.Sprintf("0.0.0.0:%d", state.TURNTLSPort), + PublicIP: "", // Will be resolved by spawner or from node info + Realm: cm.baseDomain, + AuthSecret: webrtcCfg.TURNSharedSecret, + RelayPortStart: state.TURNRelayPortStart, + RelayPortEnd: state.TURNRelayPortEnd, } if err := cm.systemdSpawner.SpawnTURN(ctx, state.NamespaceName, cm.localNodeID, turnCfg); err != nil { cm.logger.Error("Failed to restore TURN from state", zap.String("namespace", state.NamespaceName), zap.Error(err)) @@ -1992,8 +1992,8 @@ func (cm *ClusterManager) restoreClusterFromState(ctx context.Context, state *Cl MediaPortStart: state.SFUMediaPortStart, MediaPortEnd: state.SFUMediaPortEnd, TURNServers: []sfu.TURNServerConfig{ - {Host: turnDomain, Port: TURNDefaultPort}, - {Host: turnDomain, Port: TURNTLSPort}, + {Host: turnDomain, Port: TURNDefaultPort, Secure: false}, + {Host: turnDomain, Port: TURNSPort, Secure: true}, }, TURNSecret: webrtcCfg.TURNSharedSecret, TURNCredTTL: webrtcCfg.TURNCredentialTTL, diff --git a/pkg/namespace/cluster_manager_webrtc.go b/pkg/namespace/cluster_manager_webrtc.go index bd7bfba..7853109 100644 --- a/pkg/namespace/cluster_manager_webrtc.go +++ b/pkg/namespace/cluster_manager_webrtc.go @@ -102,8 +102,8 @@ func (cm *ClusterManager) EnableWebRTC(ctx context.Context, namespaceName, enabl // 9. Build TURN server list for SFU config turnDomain := fmt.Sprintf("turn.ns-%s.%s", namespaceName, cm.baseDomain) turnServers := []sfu.TURNServerConfig{ - {Host: turnDomain, Port: TURNDefaultPort}, - {Host: turnDomain, Port: TURNTLSPort}, + {Host: turnDomain, Port: TURNDefaultPort, Secure: false}, + {Host: turnDomain, Port: TURNSPort, Secure: true}, } // 10. Get port blocks for RQLite DSN @@ -123,15 +123,15 @@ func (cm *ClusterManager) EnableWebRTC(ctx context.Context, namespaceName, enabl for _, node := range turnNodes { turnBlock := turnBlocks[node.NodeID] turnCfg := TURNInstanceConfig{ - Namespace: namespaceName, - NodeID: node.NodeID, - ListenAddr: fmt.Sprintf("0.0.0.0:%d", turnBlock.TURNListenPort), - TLSListenAddr: fmt.Sprintf("0.0.0.0:%d", turnBlock.TURNTLSPort), - PublicIP: node.PublicIP, - Realm: cm.baseDomain, - AuthSecret: turnSecret, - RelayPortStart: turnBlock.TURNRelayPortStart, - RelayPortEnd: turnBlock.TURNRelayPortEnd, + Namespace: namespaceName, + NodeID: node.NodeID, + ListenAddr: fmt.Sprintf("0.0.0.0:%d", turnBlock.TURNListenPort), + TURNSListenAddr: fmt.Sprintf("0.0.0.0:%d", turnBlock.TURNTLSPort), + PublicIP: node.PublicIP, + Realm: cm.baseDomain, + AuthSecret: turnSecret, + RelayPortStart: turnBlock.TURNRelayPortStart, + RelayPortEnd: turnBlock.TURNRelayPortEnd, } if err := cm.spawnTURNOnNode(ctx, node, namespaceName, turnCfg); err != nil { @@ -184,9 +184,11 @@ func (cm *ClusterManager) EnableWebRTC(ctx context.Context, namespaceName, enabl turnIPs = append(turnIPs, node.PublicIP) } if err := cm.dnsManager.CreateTURNRecords(ctx, namespaceName, turnIPs); err != nil { - cm.logger.Warn("Failed to create TURN DNS records", + cm.logger.Error("Failed to create TURN DNS records, aborting WebRTC enablement", zap.String("namespace", namespaceName), zap.Error(err)) + cm.cleanupWebRTCOnError(ctx, cluster.ID, namespaceName, clusterNodes) + return fmt.Errorf("failed to create TURN DNS records: %w", err) } // 14. Update cluster-state.json on all nodes with WebRTC info @@ -438,8 +440,9 @@ func (cm *ClusterManager) spawnSFURemote(ctx context.Context, nodeIP string, cfg turnServers := make([]map[string]interface{}, len(cfg.TURNServers)) for i, ts := range cfg.TURNServers { turnServers[i] = map[string]interface{}{ - "host": ts.Host, - "port": ts.Port, + "host": ts.Host, + "port": ts.Port, + "secure": ts.Secure, } } @@ -465,7 +468,7 @@ func (cm *ClusterManager) spawnTURNRemote(ctx context.Context, nodeIP string, cf "namespace": cfg.Namespace, "node_id": cfg.NodeID, "turn_listen_addr": cfg.ListenAddr, - "turn_tls_addr": cfg.TLSListenAddr, + "turn_turns_addr": cfg.TURNSListenAddr, "turn_public_ip": cfg.PublicIP, "turn_realm": cfg.Realm, "turn_auth_secret": cfg.AuthSecret, diff --git a/pkg/namespace/dns_manager.go b/pkg/namespace/dns_manager.go index 40e30ca..4a2d5b7 100644 --- a/pkg/namespace/dns_manager.go +++ b/pkg/namespace/dns_manager.go @@ -61,7 +61,7 @@ func (drm *DNSRecordManager) CreateNamespaceRecords(ctx context.Context, namespa insertQuery := ` INSERT INTO dns_records ( id, fqdn, record_type, value, ttl, namespace, created_by, is_active, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, TRUE, ?, ?) ` now := time.Now() _, err := drm.db.Exec(internalCtx, insertQuery, @@ -72,7 +72,6 @@ func (drm *DNSRecordManager) CreateNamespaceRecords(ctx context.Context, namespa 60, // 60 second TTL for quick failover "namespace:"+namespaceName, // Track ownership with namespace prefix "cluster-manager", // Created by the cluster manager - true, // Active now, now, ) @@ -96,7 +95,7 @@ func (drm *DNSRecordManager) CreateNamespaceRecords(ctx context.Context, namespa insertQuery := ` INSERT INTO dns_records ( id, fqdn, record_type, value, ttl, namespace, created_by, is_active, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, TRUE, ?, ?) ` now := time.Now() _, err := drm.db.Exec(internalCtx, insertQuery, @@ -107,7 +106,6 @@ func (drm *DNSRecordManager) CreateNamespaceRecords(ctx context.Context, namespa 60, "namespace:"+namespaceName, "cluster-manager", - true, now, now, ) @@ -230,11 +228,11 @@ func (drm *DNSRecordManager) AddNamespaceRecord(ctx context.Context, namespaceNa insertQuery := ` INSERT INTO dns_records ( id, fqdn, record_type, value, ttl, namespace, created_by, is_active, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, TRUE, ?, ?) ` _, err := drm.db.Exec(internalCtx, insertQuery, recordID, f, "A", ip, 60, - "namespace:"+namespaceName, "cluster-manager", true, now, now, + "namespace:"+namespaceName, "cluster-manager", now, now, ) if err != nil { return &ClusterError{ @@ -328,13 +326,13 @@ func (drm *DNSRecordManager) CreateTURNRecords(ctx context.Context, namespaceNam insertQuery := ` INSERT INTO dns_records ( id, fqdn, record_type, value, ttl, namespace, created_by, is_active, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, TRUE, ?, ?) ` _, err := drm.db.Exec(internalCtx, insertQuery, recordID, fqdn, "A", ip, 60, "namespace-turn:"+namespaceName, "cluster-manager", - true, now, now, + now, now, ) if err != nil { return &ClusterError{ diff --git a/pkg/namespace/systemd_spawner.go b/pkg/namespace/systemd_spawner.go index 22ea196..94ea4e7 100644 --- a/pkg/namespace/systemd_spawner.go +++ b/pkg/namespace/systemd_spawner.go @@ -7,6 +7,7 @@ import ( "path/filepath" "time" + production "github.com/DeBrosOfficial/network/pkg/environments/production" "github.com/DeBrosOfficial/network/pkg/gateway" "github.com/DeBrosOfficial/network/pkg/olric" "github.com/DeBrosOfficial/network/pkg/rqlite" @@ -392,15 +393,15 @@ func (s *SystemdSpawner) StopSFU(ctx context.Context, namespace, nodeID string) // TURNInstanceConfig holds configuration for spawning a TURN instance type TURNInstanceConfig struct { - Namespace string - NodeID string - ListenAddr string // e.g., "0.0.0.0:3478" - TLSListenAddr string // e.g., "0.0.0.0:443" (UDP, no conflict with Caddy TCP) - PublicIP string // Public IP for TURN relay allocations - Realm string // TURN realm (typically base domain) - AuthSecret string // HMAC-SHA1 shared secret - RelayPortStart int // Start of relay port range - RelayPortEnd int // End of relay port range + Namespace string + NodeID string + ListenAddr string // e.g., "0.0.0.0:3478" + TURNSListenAddr string // e.g., "0.0.0.0:5349" (TURNS over TLS/TCP) + PublicIP string // Public IP for TURN relay allocations + Realm string // TURN realm (typically base domain) + AuthSecret string // HMAC-SHA1 shared secret + RelayPortStart int // Start of relay port range + RelayPortEnd int // End of relay port range } // SpawnTURN starts a TURN instance using systemd @@ -419,16 +420,38 @@ func (s *SystemdSpawner) SpawnTURN(ctx context.Context, namespace, nodeID string configPath := filepath.Join(configDir, fmt.Sprintf("turn-%s.yaml", nodeID)) + // Generate self-signed TLS cert for TURNS if not already present + certPath := filepath.Join(configDir, "turn-cert.pem") + keyPath := filepath.Join(configDir, "turn-key.pem") + if cfg.TURNSListenAddr != "" { + if _, err := os.Stat(certPath); os.IsNotExist(err) { + if err := turn.GenerateSelfSignedCert(certPath, keyPath, cfg.PublicIP); err != nil { + s.logger.Warn("Failed to generate TURNS self-signed cert, TURNS will be disabled", + zap.String("namespace", namespace), + zap.Error(err)) + cfg.TURNSListenAddr = "" // Disable TURNS if cert generation fails + } else { + s.logger.Info("Generated TURNS self-signed certificate", + zap.String("namespace", namespace), + zap.String("cert_path", certPath)) + } + } + } + // Build TURN YAML config turnConfig := turn.Config{ - ListenAddr: cfg.ListenAddr, - TLSListenAddr: cfg.TLSListenAddr, - PublicIP: cfg.PublicIP, - Realm: cfg.Realm, - AuthSecret: cfg.AuthSecret, - RelayPortStart: cfg.RelayPortStart, - RelayPortEnd: cfg.RelayPortEnd, - Namespace: cfg.Namespace, + ListenAddr: cfg.ListenAddr, + TURNSListenAddr: cfg.TURNSListenAddr, + PublicIP: cfg.PublicIP, + Realm: cfg.Realm, + AuthSecret: cfg.AuthSecret, + RelayPortStart: cfg.RelayPortStart, + RelayPortEnd: cfg.RelayPortEnd, + Namespace: cfg.Namespace, + } + if cfg.TURNSListenAddr != "" { + turnConfig.TLSCertPath = certPath + turnConfig.TLSKeyPath = keyPath } configBytes, err := yaml.Marshal(turnConfig) @@ -464,6 +487,14 @@ func (s *SystemdSpawner) SpawnTURN(ctx context.Context, namespace, nodeID string return fmt.Errorf("TURN service did not become active: %w", err) } + // Add firewall rules for TURN ports + fw := production.NewFirewallProvisioner(production.FirewallConfig{}) + if err := fw.AddWebRTCRules(cfg.RelayPortStart, cfg.RelayPortEnd); err != nil { + s.logger.Warn("Failed to add WebRTC firewall rules (TURN service is running)", + zap.String("namespace", namespace), + zap.Error(err)) + } + s.logger.Info("TURN spawned successfully via systemd", zap.String("namespace", namespace), zap.String("node_id", nodeID)) @@ -477,7 +508,17 @@ func (s *SystemdSpawner) StopTURN(ctx context.Context, namespace, nodeID string) zap.String("namespace", namespace), zap.String("node_id", nodeID)) - return s.systemdMgr.StopService(namespace, systemd.ServiceTypeTURN) + err := s.systemdMgr.StopService(namespace, systemd.ServiceTypeTURN) + + // Remove firewall rules for standard TURN ports + fw := production.NewFirewallProvisioner(production.FirewallConfig{}) + if fwErr := fw.RemoveWebRTCRules(0, 0); fwErr != nil { + s.logger.Warn("Failed to remove WebRTC firewall rules", + zap.String("namespace", namespace), + zap.Error(fwErr)) + } + + return err } // SaveClusterState writes cluster state JSON to the namespace data directory. diff --git a/pkg/namespace/types.go b/pkg/namespace/types.go index 9f77de7..2ee5550 100644 --- a/pkg/namespace/types.go +++ b/pkg/namespace/types.go @@ -110,8 +110,8 @@ const ( TURNRelayPortsPerNamespace = 800 // TURN listen ports (standard) - TURNDefaultPort = 3478 - TURNTLSPort = 443 + TURNDefaultPort = 3478 + TURNSPort = 5349 // TURNS (TURN over TLS on TCP) // Default TURN credential TTL in seconds (10 minutes) DefaultTURNCredentialTTL = 600 diff --git a/pkg/namespace/webrtc_port_allocator.go b/pkg/namespace/webrtc_port_allocator.go index 8f21149..c9b9f47 100644 --- a/pkg/namespace/webrtc_port_allocator.go +++ b/pkg/namespace/webrtc_port_allocator.go @@ -217,7 +217,7 @@ func (wpa *WebRTCPortAllocator) tryAllocateTURNPorts(ctx context.Context, nodeID NamespaceClusterID: namespaceClusterID, ServiceType: "turn", TURNListenPort: TURNDefaultPort, - TURNTLSPort: TURNTLSPort, + TURNTLSPort: TURNSPort, TURNRelayPortStart: relayStart, TURNRelayPortEnd: relayStart + TURNRelayPortsPerNamespace - 1, AllocatedAt: time.Now(), diff --git a/pkg/namespace/webrtc_port_allocator_test.go b/pkg/namespace/webrtc_port_allocator_test.go index ea217a1..bad6044 100644 --- a/pkg/namespace/webrtc_port_allocator_test.go +++ b/pkg/namespace/webrtc_port_allocator_test.go @@ -168,8 +168,8 @@ func TestWebRTCPortAllocator_AllocateTURNPorts(t *testing.T) { if block.TURNListenPort != TURNDefaultPort { t.Errorf("TURNListenPort = %d, want %d", block.TURNListenPort, TURNDefaultPort) } - if block.TURNTLSPort != TURNTLSPort { - t.Errorf("TURNTLSPort = %d, want %d", block.TURNTLSPort, TURNTLSPort) + if block.TURNTLSPort != TURNSPort { + t.Errorf("TURNTLSPort = %d, want %d", block.TURNTLSPort, TURNSPort) } if block.TURNRelayPortStart != TURNRelayPortRangeStart { t.Errorf("TURNRelayPortStart = %d, want %d", block.TURNRelayPortStart, TURNRelayPortRangeStart) @@ -320,7 +320,7 @@ func TestWebRTCPortBlock_TURNFields(t *testing.T) { NamespaceClusterID: "cluster-1", ServiceType: "turn", TURNListenPort: 3478, - TURNTLSPort: 443, + TURNTLSPort: 5349, TURNRelayPortStart: 49152, TURNRelayPortEnd: 49951, } diff --git a/pkg/sfu/config.go b/pkg/sfu/config.go index ac71a40..3861771 100644 --- a/pkg/sfu/config.go +++ b/pkg/sfu/config.go @@ -30,8 +30,9 @@ type Config struct { // TURNServerConfig represents a single TURN server endpoint type TURNServerConfig struct { - Host string `yaml:"host"` // IP or hostname - Port int `yaml:"port"` // UDP port (3478 or 443) + Host string `yaml:"host"` // IP or hostname + Port int `yaml:"port"` // Port number (3478 for TURN, 5349 for TURNS) + Secure bool `yaml:"secure"` // true = TURNS (TLS over TCP), false = TURN (UDP) } // Validate checks the SFU configuration for errors diff --git a/pkg/sfu/room.go b/pkg/sfu/room.go index adb4477..3e11f5c 100644 --- a/pkg/sfu/room.go +++ b/pkg/sfu/room.go @@ -539,7 +539,12 @@ func (r *Room) buildICEServers() []webrtc.ICEServer { var urls []string for _, ts := range r.config.TURNServers { - urls = append(urls, fmt.Sprintf("turn:%s:%d?transport=udp", ts.Host, ts.Port)) + if ts.Secure { + urls = append(urls, fmt.Sprintf("turns:%s:%d", ts.Host, ts.Port)) + } else { + urls = append(urls, fmt.Sprintf("turn:%s:%d?transport=udp", ts.Host, ts.Port)) + urls = append(urls, fmt.Sprintf("turn:%s:%d?transport=tcp", ts.Host, ts.Port)) + } } ttl := time.Duration(r.config.TURNCredentialTTL) * time.Second diff --git a/pkg/sfu/room_test.go b/pkg/sfu/room_test.go index 62d1e46..bc9499e 100644 --- a/pkg/sfu/room_test.go +++ b/pkg/sfu/room_test.go @@ -179,11 +179,14 @@ func TestRoomBuildICEServers(t *testing.T) { if len(servers) != 1 { t.Fatalf("ICE servers count = %d, want 1", len(servers)) } - if len(servers[0].URLs) != 1 { - t.Fatalf("URLs count = %d, want 1", len(servers[0].URLs)) + if len(servers[0].URLs) != 2 { + t.Fatalf("URLs count = %d, want 2", len(servers[0].URLs)) } if servers[0].URLs[0] != "turn:1.2.3.4:3478?transport=udp" { - t.Errorf("URL = %q, want %q", servers[0].URLs[0], "turn:1.2.3.4:3478?transport=udp") + t.Errorf("URL[0] = %q, want %q", servers[0].URLs[0], "turn:1.2.3.4:3478?transport=udp") + } + if servers[0].URLs[1] != "turn:1.2.3.4:3478?transport=tcp" { + t.Errorf("URL[1] = %q, want %q", servers[0].URLs[1], "turn:1.2.3.4:3478?transport=tcp") } if servers[0].Username == "" { t.Error("Username should not be empty") @@ -222,8 +225,8 @@ func TestRoomBuildICEServersNoSecret(t *testing.T) { func TestRoomBuildICEServersMultipleTURN(t *testing.T) { cfg := testConfig() cfg.TURNServers = []TURNServerConfig{ - {Host: "1.2.3.4", Port: 3478}, - {Host: "5.6.7.8", Port: 443}, + {Host: "1.2.3.4", Port: 3478}, // non-secure → UDP + TCP = 2 URIs + {Host: "5.6.7.8", Port: 5349, Secure: true}, // secure → 1 URI } rm := NewRoomManager(cfg, testLogger()) @@ -233,8 +236,9 @@ func TestRoomBuildICEServersMultipleTURN(t *testing.T) { if len(servers) != 1 { t.Fatalf("ICE servers count = %d, want 1", len(servers)) } - if len(servers[0].URLs) != 2 { - t.Fatalf("URLs count = %d, want 2", len(servers[0].URLs)) + // 1 non-secure (UDP+TCP) + 1 secure (TURNS) = 3 URIs + if len(servers[0].URLs) != 3 { + t.Fatalf("URLs count = %d, want 3", len(servers[0].URLs)) } } diff --git a/pkg/sfu/server.go b/pkg/sfu/server.go index 49c803f..f1cceb4 100644 --- a/pkg/sfu/server.go +++ b/pkg/sfu/server.go @@ -257,7 +257,12 @@ func (s *Server) sendTURNCredentials(peer *Peer) { var uris []string for _, ts := range s.config.TURNServers { - uris = append(uris, fmt.Sprintf("turn:%s:%d?transport=udp", ts.Host, ts.Port)) + if ts.Secure { + uris = append(uris, fmt.Sprintf("turns:%s:%d", ts.Host, ts.Port)) + } else { + uris = append(uris, fmt.Sprintf("turn:%s:%d?transport=udp", ts.Host, ts.Port)) + uris = append(uris, fmt.Sprintf("turn:%s:%d?transport=tcp", ts.Host, ts.Port)) + } } peer.SendMessage(NewServerMessage(MessageTypeTURNCredentials, &TURNCredentialsData{ diff --git a/pkg/sfu/signaling_test.go b/pkg/sfu/signaling_test.go index cecbdd8..157602a 100644 --- a/pkg/sfu/signaling_test.go +++ b/pkg/sfu/signaling_test.go @@ -233,7 +233,7 @@ func TestTURNCredentialsDataSerialization(t *testing.T) { Username: "1234567890:test-ns", Password: "base64password==", TTL: 600, - URIs: []string{"turn:1.2.3.4:3478?transport=udp", "turn:5.6.7.8:443?transport=udp"}, + URIs: []string{"turn:1.2.3.4:3478?transport=udp", "turns:5.6.7.8:5349"}, } data, err := json.Marshal(creds) diff --git a/pkg/turn/config.go b/pkg/turn/config.go index 61bd809..0b9bb49 100644 --- a/pkg/turn/config.go +++ b/pkg/turn/config.go @@ -10,9 +10,14 @@ type Config struct { // ListenAddr is the address to bind the TURN listener (e.g., "0.0.0.0:3478") ListenAddr string `yaml:"listen_addr"` - // TLSListenAddr is the address for TURN over TLS/DTLS (e.g., "0.0.0.0:443") - // Uses UDP 443 — requires Caddy HTTP/3 (QUIC) to be disabled to avoid port conflict - TLSListenAddr string `yaml:"tls_listen_addr"` + // TURNSListenAddr is the address for TURNS (TURN over TLS on TCP, e.g., "0.0.0.0:5349") + TURNSListenAddr string `yaml:"turns_listen_addr"` + + // TLSCertPath is the path to the TLS certificate PEM file (for TURNS) + TLSCertPath string `yaml:"tls_cert_path"` + + // TLSKeyPath is the path to the TLS private key PEM file (for TURNS) + TLSKeyPath string `yaml:"tls_key_path"` // PublicIP is the public IP address of this node, advertised in TURN allocations PublicIP string `yaml:"public_ip"` diff --git a/pkg/turn/server.go b/pkg/turn/server.go index 077e5da..c80a2f9 100644 --- a/pkg/turn/server.go +++ b/pkg/turn/server.go @@ -3,6 +3,7 @@ package turn import ( "crypto/hmac" "crypto/sha1" + "crypto/tls" "encoding/base64" "fmt" "net" @@ -16,11 +17,12 @@ import ( // 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) - tlsConn net.PacketConn // UDP listener on TLS port (443) + 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) } // NewServer creates and starts a TURN server. @@ -58,18 +60,45 @@ func NewServer(cfg *Config, logger *zap.Logger) (*Server, error) { }, } - // Create TLS UDP listener (port 443) if configured - // Requires Caddy HTTP/3 (QUIC) to be disabled to avoid UDP 443 conflict - if cfg.TLSListenAddr != "" { - tlsConn, err := net.ListenPacket("udp4", cfg.TLSListenAddr) + // 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 + if cfg.TURNSListenAddr != "" && cfg.TLSCertPath != "" && cfg.TLSKeyPath != "" { + cert, err := tls.LoadX509KeyPair(cfg.TLSCertPath, cfg.TLSKeyPath) if err != nil { conn.Close() - return nil, fmt.Errorf("failed to listen on %s: %w", cfg.TLSListenAddr, err) + return nil, fmt.Errorf("failed to load TLS cert/key: %w", err) } - s.tlsConn = tlsConn + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + } + tlsListener, err := tls.Listen("tcp", cfg.TURNSListenAddr, tlsConfig) + if err != nil { + conn.Close() + return nil, fmt.Errorf("failed to listen on %s: %w", cfg.TURNSListenAddr, err) + } + s.tlsListener = tlsListener - packetConfigs = append(packetConfigs, pionTurn.PacketConnConfig{ - PacketConn: tlsConn, + listenerConfigs = append(listenerConfigs, pionTurn.ListenerConfig{ + Listener: tlsListener, RelayAddressGenerator: &pionTurn.RelayAddressGeneratorPortRange{ RelayAddress: relayIP, Address: "0.0.0.0", @@ -80,13 +109,17 @@ func NewServer(cfg *Config, logger *zap.Logger) (*Server, error) { } // Create TURN server with HMAC-SHA1 auth - turnServer, err := pionTurn.NewServer(pionTurn.ServerConfig{ + 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) @@ -94,8 +127,9 @@ func NewServer(cfg *Config, logger *zap.Logger) (*Server, error) { s.turnServer = turnServer s.logger.Info("TURN server started", - zap.String("listen_addr", cfg.ListenAddr), - zap.String("tls_listen_addr", cfg.TLSListenAddr), + 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), @@ -178,9 +212,13 @@ func (s *Server) closeListeners() { s.conn.Close() s.conn = nil } - if s.tlsConn != nil { - s.tlsConn.Close() - s.tlsConn = nil + if s.tcpListener != nil { + s.tcpListener.Close() + s.tcpListener = nil + } + if s.tlsListener != nil { + s.tlsListener.Close() + s.tlsListener = nil } } diff --git a/pkg/turn/tls.go b/pkg/turn/tls.go new file mode 100644 index 0000000..01614c0 --- /dev/null +++ b/pkg/turn/tls.go @@ -0,0 +1,83 @@ +package turn + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net" + "os" + "time" +) + +// GenerateSelfSignedCert generates a self-signed TLS certificate for TURNS. +// The certificate is valid for 1 year and includes the public IP as a SAN. +func GenerateSelfSignedCert(certPath, keyPath, publicIP string) error { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return fmt.Errorf("failed to generate private key: %w", err) + } + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return fmt.Errorf("failed to generate serial number: %w", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Orama Network"}, + CommonName: "TURN Server", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + if ip := net.ParseIP(publicIP); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } + + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + if err != nil { + return fmt.Errorf("failed to create certificate: %w", err) + } + + certFile, err := os.Create(certPath) + if err != nil { + return fmt.Errorf("failed to create cert file: %w", err) + } + defer certFile.Close() + + if err := pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil { + return fmt.Errorf("failed to write cert PEM: %w", err) + } + + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return fmt.Errorf("failed to marshal private key: %w", err) + } + + keyFile, err := os.Create(keyPath) + if err != nil { + return fmt.Errorf("failed to create key file: %w", err) + } + defer keyFile.Close() + + if err := pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil { + return fmt.Errorf("failed to write key PEM: %w", err) + } + + // Restrict key file permissions + if err := os.Chmod(keyPath, 0600); err != nil { + return fmt.Errorf("failed to set key file permissions: %w", err) + } + + return nil +}