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.
This commit is contained in:
anonpenguin23 2026-02-23 16:32:32 +02:00
parent bcfdabb32d
commit 714a986a78
23 changed files with 329 additions and 121 deletions

View File

@ -63,7 +63,7 @@ test-e2e-quick:
.PHONY: build clean test deps tidy fmt vet lint install-hooks redeploy-devnet redeploy-testnet release health .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) COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.commit=$(COMMIT)' -X 'main.date=$(DATE)' LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.commit=$(COMMIT)' -X 'main.date=$(DATE)'

View File

@ -1,6 +1,8 @@
package main package main
import ( import (
"errors"
"net/http"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
@ -35,7 +37,7 @@ func main() {
// Start HTTP server in background // Start HTTP server in background
go func() { 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)) logger.ComponentError(logging.ComponentSFU, "SFU server error", zap.Error(err))
os.Exit(1) os.Exit(1)
} }

View File

@ -40,14 +40,16 @@ func parseTURNConfig(logger *logging.ColoredLogger) *turn.Config {
} }
type yamlCfg struct { type yamlCfg struct {
ListenAddr string `yaml:"listen_addr"` ListenAddr string `yaml:"listen_addr"`
TLSListenAddr string `yaml:"tls_listen_addr"` TURNSListenAddr string `yaml:"turns_listen_addr"`
PublicIP string `yaml:"public_ip"` PublicIP string `yaml:"public_ip"`
Realm string `yaml:"realm"` Realm string `yaml:"realm"`
AuthSecret string `yaml:"auth_secret"` AuthSecret string `yaml:"auth_secret"`
RelayPortStart int `yaml:"relay_port_start"` RelayPortStart int `yaml:"relay_port_start"`
RelayPortEnd int `yaml:"relay_port_end"` RelayPortEnd int `yaml:"relay_port_end"`
Namespace string `yaml:"namespace"` Namespace string `yaml:"namespace"`
TLSCertPath string `yaml:"tls_cert_path"`
TLSKeyPath string `yaml:"tls_key_path"`
} }
data, err := os.ReadFile(configPath) data, err := os.ReadFile(configPath)
@ -66,14 +68,16 @@ func parseTURNConfig(logger *logging.ColoredLogger) *turn.Config {
} }
cfg := &turn.Config{ cfg := &turn.Config{
ListenAddr: y.ListenAddr, ListenAddr: y.ListenAddr,
TLSListenAddr: y.TLSListenAddr, TURNSListenAddr: y.TURNSListenAddr,
PublicIP: y.PublicIP, PublicIP: y.PublicIP,
Realm: y.Realm, Realm: y.Realm,
AuthSecret: y.AuthSecret, AuthSecret: y.AuthSecret,
RelayPortStart: y.RelayPortStart, RelayPortStart: y.RelayPortStart,
RelayPortEnd: y.RelayPortEnd, RelayPortEnd: y.RelayPortEnd,
Namespace: y.Namespace, Namespace: y.Namespace,
TLSCertPath: y.TLSCertPath,
TLSKeyPath: y.TLSKeyPath,
} }
if errs := cfg.Validate(); len(errs) > 0 { if errs := cfg.Validate(); len(errs) > 0 {

View File

@ -12,7 +12,7 @@ type FirewallConfig struct {
IsNameserver bool // enables port 53 TCP+UDP IsNameserver bool // enables port 53 TCP+UDP
AnyoneORPort int // 0 = disabled, typically 9001 AnyoneORPort int // 0 = disabled, typically 9001
WireGuardPort int // default 51820 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) TURNRelayStart int // start of TURN relay port range (default 49152)
TURNRelayEnd int // end of TURN relay port range (default 65535) 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) // TURN relay (only for nodes running TURN servers)
if fp.config.TURNEnabled { if fp.config.TURNEnabled {
rules = append(rules, "ufw allow 3478/udp") // TURN standard port rules = append(rules, "ufw allow 3478/udp") // TURN standard port (UDP)
rules = append(rules, "ufw allow 443/udp") // TURN TLS port (does not conflict with Caddy TCP 443) 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 { 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)) 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 { func (fp *FirewallProvisioner) AddWebRTCRules(relayStart, relayEnd int) error {
rules := []string{ rules := []string{
"ufw allow 3478/udp", "ufw allow 3478/udp",
"ufw allow 443/udp", "ufw allow 3478/tcp",
"ufw allow 5349/tcp",
} }
if relayStart > 0 && relayEnd > 0 { if relayStart > 0 && relayEnd > 0 {
rules = append(rules, fmt.Sprintf("ufw allow %d:%d/udp", relayStart, relayEnd)) 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 { func (fp *FirewallProvisioner) RemoveWebRTCRules(relayStart, relayEnd int) error {
rules := []string{ rules := []string{
"ufw delete allow 3478/udp", "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 { if relayStart > 0 && relayEnd > 0 {
rules = append(rules, fmt.Sprintf("ufw delete allow %d:%d/udp", relayStart, relayEnd)) rules = append(rules, fmt.Sprintf("ufw delete allow %d:%d/udp", relayStart, relayEnd))

View File

@ -96,6 +96,21 @@ func TestFirewallProvisioner_GenerateRules_FullConfig(t *testing.T) {
assertContainsRule(t, rules, "ufw allow 9001/tcp") 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) { func TestFirewallProvisioner_DefaultPorts(t *testing.T) {
fp := NewFirewallProvisioner(FirewallConfig{}) fp := NewFirewallProvisioner(FirewallConfig{})

View File

@ -65,7 +65,7 @@ type SpawnRequest struct {
// TURN config (when action = "spawn-turn") // TURN config (when action = "spawn-turn")
TURNListenAddr string `json:"turn_listen_addr,omitempty"` 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"` TURNPublicIP string `json:"turn_public_ip,omitempty"`
TURNRealm string `json:"turn_realm,omitempty"` TURNRealm string `json:"turn_realm,omitempty"`
TURNAuthSecret string `json:"turn_auth_secret,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, Namespace: req.Namespace,
NodeID: req.NodeID, NodeID: req.NodeID,
ListenAddr: req.TURNListenAddr, ListenAddr: req.TURNListenAddr,
TLSListenAddr: req.TURNTLSAddr, TURNSListenAddr: req.TURNTURNSAddr,
PublicIP: req.TURNPublicIP, PublicIP: req.TURNPublicIP,
Realm: req.TURNRealm, Realm: req.TURNRealm,
AuthSecret: req.TURNAuthSecret, AuthSecret: req.TURNAuthSecret,

View File

@ -38,7 +38,8 @@ func (h *WebRTCHandlers) CredentialsHandler(w http.ResponseWriter, r *http.Reque
if h.turnDomain != "" { if h.turnDomain != "" {
uris = append(uris, uris = append(uris,
fmt.Sprintf("turn:%s:3478?transport=udp", h.turnDomain), 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),
) )
} }

View File

@ -61,8 +61,8 @@ func TestCredentialsHandler_Success(t *testing.T) {
t.Errorf("ttl = %v, want 600", result["ttl"]) t.Errorf("ttl = %v, want 600", result["ttl"])
} }
uris, ok := result["uris"].([]interface{}) uris, ok := result["uris"].([]interface{})
if !ok || len(uris) != 2 { if !ok || len(uris) != 3 {
t.Errorf("uris count = %v, want 2", result["uris"]) t.Errorf("uris count = %v, want 3", result["uris"])
} }
} }

View File

@ -1956,15 +1956,15 @@ func (cm *ClusterManager) restoreClusterFromState(ctx context.Context, state *Cl
webrtcCfg, err := cm.GetWebRTCConfig(ctx, state.NamespaceName) webrtcCfg, err := cm.GetWebRTCConfig(ctx, state.NamespaceName)
if err == nil && webrtcCfg != nil { if err == nil && webrtcCfg != nil {
turnCfg := TURNInstanceConfig{ turnCfg := TURNInstanceConfig{
Namespace: state.NamespaceName, Namespace: state.NamespaceName,
NodeID: cm.localNodeID, NodeID: cm.localNodeID,
ListenAddr: fmt.Sprintf("0.0.0.0:%d", state.TURNListenPort), ListenAddr: fmt.Sprintf("0.0.0.0:%d", state.TURNListenPort),
TLSListenAddr: fmt.Sprintf("0.0.0.0:%d", state.TURNTLSPort), TURNSListenAddr: fmt.Sprintf("0.0.0.0:%d", state.TURNTLSPort),
PublicIP: "", // Will be resolved by spawner or from node info PublicIP: "", // Will be resolved by spawner or from node info
Realm: cm.baseDomain, Realm: cm.baseDomain,
AuthSecret: webrtcCfg.TURNSharedSecret, AuthSecret: webrtcCfg.TURNSharedSecret,
RelayPortStart: state.TURNRelayPortStart, RelayPortStart: state.TURNRelayPortStart,
RelayPortEnd: state.TURNRelayPortEnd, RelayPortEnd: state.TURNRelayPortEnd,
} }
if err := cm.systemdSpawner.SpawnTURN(ctx, state.NamespaceName, cm.localNodeID, turnCfg); err != nil { 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)) 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, MediaPortStart: state.SFUMediaPortStart,
MediaPortEnd: state.SFUMediaPortEnd, MediaPortEnd: state.SFUMediaPortEnd,
TURNServers: []sfu.TURNServerConfig{ TURNServers: []sfu.TURNServerConfig{
{Host: turnDomain, Port: TURNDefaultPort}, {Host: turnDomain, Port: TURNDefaultPort, Secure: false},
{Host: turnDomain, Port: TURNTLSPort}, {Host: turnDomain, Port: TURNSPort, Secure: true},
}, },
TURNSecret: webrtcCfg.TURNSharedSecret, TURNSecret: webrtcCfg.TURNSharedSecret,
TURNCredTTL: webrtcCfg.TURNCredentialTTL, TURNCredTTL: webrtcCfg.TURNCredentialTTL,

View File

@ -102,8 +102,8 @@ func (cm *ClusterManager) EnableWebRTC(ctx context.Context, namespaceName, enabl
// 9. Build TURN server list for SFU config // 9. Build TURN server list for SFU config
turnDomain := fmt.Sprintf("turn.ns-%s.%s", namespaceName, cm.baseDomain) turnDomain := fmt.Sprintf("turn.ns-%s.%s", namespaceName, cm.baseDomain)
turnServers := []sfu.TURNServerConfig{ turnServers := []sfu.TURNServerConfig{
{Host: turnDomain, Port: TURNDefaultPort}, {Host: turnDomain, Port: TURNDefaultPort, Secure: false},
{Host: turnDomain, Port: TURNTLSPort}, {Host: turnDomain, Port: TURNSPort, Secure: true},
} }
// 10. Get port blocks for RQLite DSN // 10. Get port blocks for RQLite DSN
@ -123,15 +123,15 @@ func (cm *ClusterManager) EnableWebRTC(ctx context.Context, namespaceName, enabl
for _, node := range turnNodes { for _, node := range turnNodes {
turnBlock := turnBlocks[node.NodeID] turnBlock := turnBlocks[node.NodeID]
turnCfg := TURNInstanceConfig{ turnCfg := TURNInstanceConfig{
Namespace: namespaceName, Namespace: namespaceName,
NodeID: node.NodeID, NodeID: node.NodeID,
ListenAddr: fmt.Sprintf("0.0.0.0:%d", turnBlock.TURNListenPort), ListenAddr: fmt.Sprintf("0.0.0.0:%d", turnBlock.TURNListenPort),
TLSListenAddr: fmt.Sprintf("0.0.0.0:%d", turnBlock.TURNTLSPort), TURNSListenAddr: fmt.Sprintf("0.0.0.0:%d", turnBlock.TURNTLSPort),
PublicIP: node.PublicIP, PublicIP: node.PublicIP,
Realm: cm.baseDomain, Realm: cm.baseDomain,
AuthSecret: turnSecret, AuthSecret: turnSecret,
RelayPortStart: turnBlock.TURNRelayPortStart, RelayPortStart: turnBlock.TURNRelayPortStart,
RelayPortEnd: turnBlock.TURNRelayPortEnd, RelayPortEnd: turnBlock.TURNRelayPortEnd,
} }
if err := cm.spawnTURNOnNode(ctx, node, namespaceName, turnCfg); err != nil { 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) turnIPs = append(turnIPs, node.PublicIP)
} }
if err := cm.dnsManager.CreateTURNRecords(ctx, namespaceName, turnIPs); err != nil { 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.String("namespace", namespaceName),
zap.Error(err)) 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 // 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)) turnServers := make([]map[string]interface{}, len(cfg.TURNServers))
for i, ts := range cfg.TURNServers { for i, ts := range cfg.TURNServers {
turnServers[i] = map[string]interface{}{ turnServers[i] = map[string]interface{}{
"host": ts.Host, "host": ts.Host,
"port": ts.Port, "port": ts.Port,
"secure": ts.Secure,
} }
} }
@ -465,7 +468,7 @@ func (cm *ClusterManager) spawnTURNRemote(ctx context.Context, nodeIP string, cf
"namespace": cfg.Namespace, "namespace": cfg.Namespace,
"node_id": cfg.NodeID, "node_id": cfg.NodeID,
"turn_listen_addr": cfg.ListenAddr, "turn_listen_addr": cfg.ListenAddr,
"turn_tls_addr": cfg.TLSListenAddr, "turn_turns_addr": cfg.TURNSListenAddr,
"turn_public_ip": cfg.PublicIP, "turn_public_ip": cfg.PublicIP,
"turn_realm": cfg.Realm, "turn_realm": cfg.Realm,
"turn_auth_secret": cfg.AuthSecret, "turn_auth_secret": cfg.AuthSecret,

View File

@ -61,7 +61,7 @@ func (drm *DNSRecordManager) CreateNamespaceRecords(ctx context.Context, namespa
insertQuery := ` insertQuery := `
INSERT INTO dns_records ( INSERT INTO dns_records (
id, fqdn, record_type, value, ttl, namespace, created_by, is_active, created_at, updated_at id, fqdn, record_type, value, ttl, namespace, created_by, is_active, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, TRUE, ?, ?)
` `
now := time.Now() now := time.Now()
_, err := drm.db.Exec(internalCtx, insertQuery, _, 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 60, // 60 second TTL for quick failover
"namespace:"+namespaceName, // Track ownership with namespace prefix "namespace:"+namespaceName, // Track ownership with namespace prefix
"cluster-manager", // Created by the cluster manager "cluster-manager", // Created by the cluster manager
true, // Active
now, now,
now, now,
) )
@ -96,7 +95,7 @@ func (drm *DNSRecordManager) CreateNamespaceRecords(ctx context.Context, namespa
insertQuery := ` insertQuery := `
INSERT INTO dns_records ( INSERT INTO dns_records (
id, fqdn, record_type, value, ttl, namespace, created_by, is_active, created_at, updated_at id, fqdn, record_type, value, ttl, namespace, created_by, is_active, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, TRUE, ?, ?)
` `
now := time.Now() now := time.Now()
_, err := drm.db.Exec(internalCtx, insertQuery, _, err := drm.db.Exec(internalCtx, insertQuery,
@ -107,7 +106,6 @@ func (drm *DNSRecordManager) CreateNamespaceRecords(ctx context.Context, namespa
60, 60,
"namespace:"+namespaceName, "namespace:"+namespaceName,
"cluster-manager", "cluster-manager",
true,
now, now,
now, now,
) )
@ -230,11 +228,11 @@ func (drm *DNSRecordManager) AddNamespaceRecord(ctx context.Context, namespaceNa
insertQuery := ` insertQuery := `
INSERT INTO dns_records ( INSERT INTO dns_records (
id, fqdn, record_type, value, ttl, namespace, created_by, is_active, created_at, updated_at id, fqdn, record_type, value, ttl, namespace, created_by, is_active, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, TRUE, ?, ?)
` `
_, err := drm.db.Exec(internalCtx, insertQuery, _, err := drm.db.Exec(internalCtx, insertQuery,
recordID, f, "A", ip, 60, recordID, f, "A", ip, 60,
"namespace:"+namespaceName, "cluster-manager", true, now, now, "namespace:"+namespaceName, "cluster-manager", now, now,
) )
if err != nil { if err != nil {
return &ClusterError{ return &ClusterError{
@ -328,13 +326,13 @@ func (drm *DNSRecordManager) CreateTURNRecords(ctx context.Context, namespaceNam
insertQuery := ` insertQuery := `
INSERT INTO dns_records ( INSERT INTO dns_records (
id, fqdn, record_type, value, ttl, namespace, created_by, is_active, created_at, updated_at id, fqdn, record_type, value, ttl, namespace, created_by, is_active, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, TRUE, ?, ?)
` `
_, err := drm.db.Exec(internalCtx, insertQuery, _, err := drm.db.Exec(internalCtx, insertQuery,
recordID, fqdn, "A", ip, 60, recordID, fqdn, "A", ip, 60,
"namespace-turn:"+namespaceName, "namespace-turn:"+namespaceName,
"cluster-manager", "cluster-manager",
true, now, now, now, now,
) )
if err != nil { if err != nil {
return &ClusterError{ return &ClusterError{

View File

@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
"time" "time"
production "github.com/DeBrosOfficial/network/pkg/environments/production"
"github.com/DeBrosOfficial/network/pkg/gateway" "github.com/DeBrosOfficial/network/pkg/gateway"
"github.com/DeBrosOfficial/network/pkg/olric" "github.com/DeBrosOfficial/network/pkg/olric"
"github.com/DeBrosOfficial/network/pkg/rqlite" "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 // TURNInstanceConfig holds configuration for spawning a TURN instance
type TURNInstanceConfig struct { type TURNInstanceConfig struct {
Namespace string Namespace string
NodeID string NodeID string
ListenAddr string // e.g., "0.0.0.0:3478" ListenAddr string // e.g., "0.0.0.0:3478"
TLSListenAddr string // e.g., "0.0.0.0:443" (UDP, no conflict with Caddy TCP) TURNSListenAddr string // e.g., "0.0.0.0:5349" (TURNS over TLS/TCP)
PublicIP string // Public IP for TURN relay allocations PublicIP string // Public IP for TURN relay allocations
Realm string // TURN realm (typically base domain) Realm string // TURN realm (typically base domain)
AuthSecret string // HMAC-SHA1 shared secret AuthSecret string // HMAC-SHA1 shared secret
RelayPortStart int // Start of relay port range RelayPortStart int // Start of relay port range
RelayPortEnd int // End of relay port range RelayPortEnd int // End of relay port range
} }
// SpawnTURN starts a TURN instance using systemd // 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)) 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 // Build TURN YAML config
turnConfig := turn.Config{ turnConfig := turn.Config{
ListenAddr: cfg.ListenAddr, ListenAddr: cfg.ListenAddr,
TLSListenAddr: cfg.TLSListenAddr, TURNSListenAddr: cfg.TURNSListenAddr,
PublicIP: cfg.PublicIP, PublicIP: cfg.PublicIP,
Realm: cfg.Realm, Realm: cfg.Realm,
AuthSecret: cfg.AuthSecret, AuthSecret: cfg.AuthSecret,
RelayPortStart: cfg.RelayPortStart, RelayPortStart: cfg.RelayPortStart,
RelayPortEnd: cfg.RelayPortEnd, RelayPortEnd: cfg.RelayPortEnd,
Namespace: cfg.Namespace, Namespace: cfg.Namespace,
}
if cfg.TURNSListenAddr != "" {
turnConfig.TLSCertPath = certPath
turnConfig.TLSKeyPath = keyPath
} }
configBytes, err := yaml.Marshal(turnConfig) 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) 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", s.logger.Info("TURN spawned successfully via systemd",
zap.String("namespace", namespace), zap.String("namespace", namespace),
zap.String("node_id", nodeID)) 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("namespace", namespace),
zap.String("node_id", nodeID)) 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. // SaveClusterState writes cluster state JSON to the namespace data directory.

View File

@ -110,8 +110,8 @@ const (
TURNRelayPortsPerNamespace = 800 TURNRelayPortsPerNamespace = 800
// TURN listen ports (standard) // TURN listen ports (standard)
TURNDefaultPort = 3478 TURNDefaultPort = 3478
TURNTLSPort = 443 TURNSPort = 5349 // TURNS (TURN over TLS on TCP)
// Default TURN credential TTL in seconds (10 minutes) // Default TURN credential TTL in seconds (10 minutes)
DefaultTURNCredentialTTL = 600 DefaultTURNCredentialTTL = 600

View File

@ -217,7 +217,7 @@ func (wpa *WebRTCPortAllocator) tryAllocateTURNPorts(ctx context.Context, nodeID
NamespaceClusterID: namespaceClusterID, NamespaceClusterID: namespaceClusterID,
ServiceType: "turn", ServiceType: "turn",
TURNListenPort: TURNDefaultPort, TURNListenPort: TURNDefaultPort,
TURNTLSPort: TURNTLSPort, TURNTLSPort: TURNSPort,
TURNRelayPortStart: relayStart, TURNRelayPortStart: relayStart,
TURNRelayPortEnd: relayStart + TURNRelayPortsPerNamespace - 1, TURNRelayPortEnd: relayStart + TURNRelayPortsPerNamespace - 1,
AllocatedAt: time.Now(), AllocatedAt: time.Now(),

View File

@ -168,8 +168,8 @@ func TestWebRTCPortAllocator_AllocateTURNPorts(t *testing.T) {
if block.TURNListenPort != TURNDefaultPort { if block.TURNListenPort != TURNDefaultPort {
t.Errorf("TURNListenPort = %d, want %d", block.TURNListenPort, TURNDefaultPort) t.Errorf("TURNListenPort = %d, want %d", block.TURNListenPort, TURNDefaultPort)
} }
if block.TURNTLSPort != TURNTLSPort { if block.TURNTLSPort != TURNSPort {
t.Errorf("TURNTLSPort = %d, want %d", block.TURNTLSPort, TURNTLSPort) t.Errorf("TURNTLSPort = %d, want %d", block.TURNTLSPort, TURNSPort)
} }
if block.TURNRelayPortStart != TURNRelayPortRangeStart { if block.TURNRelayPortStart != TURNRelayPortRangeStart {
t.Errorf("TURNRelayPortStart = %d, want %d", 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", NamespaceClusterID: "cluster-1",
ServiceType: "turn", ServiceType: "turn",
TURNListenPort: 3478, TURNListenPort: 3478,
TURNTLSPort: 443, TURNTLSPort: 5349,
TURNRelayPortStart: 49152, TURNRelayPortStart: 49152,
TURNRelayPortEnd: 49951, TURNRelayPortEnd: 49951,
} }

View File

@ -30,8 +30,9 @@ type Config struct {
// TURNServerConfig represents a single TURN server endpoint // TURNServerConfig represents a single TURN server endpoint
type TURNServerConfig struct { type TURNServerConfig struct {
Host string `yaml:"host"` // IP or hostname Host string `yaml:"host"` // IP or hostname
Port int `yaml:"port"` // UDP port (3478 or 443) 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 // Validate checks the SFU configuration for errors

View File

@ -539,7 +539,12 @@ func (r *Room) buildICEServers() []webrtc.ICEServer {
var urls []string var urls []string
for _, ts := range r.config.TURNServers { 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 ttl := time.Duration(r.config.TURNCredentialTTL) * time.Second

View File

@ -179,11 +179,14 @@ func TestRoomBuildICEServers(t *testing.T) {
if len(servers) != 1 { if len(servers) != 1 {
t.Fatalf("ICE servers count = %d, want 1", len(servers)) t.Fatalf("ICE servers count = %d, want 1", len(servers))
} }
if len(servers[0].URLs) != 1 { if len(servers[0].URLs) != 2 {
t.Fatalf("URLs count = %d, want 1", len(servers[0].URLs)) t.Fatalf("URLs count = %d, want 2", len(servers[0].URLs))
} }
if servers[0].URLs[0] != "turn:1.2.3.4:3478?transport=udp" { 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 == "" { if servers[0].Username == "" {
t.Error("Username should not be empty") t.Error("Username should not be empty")
@ -222,8 +225,8 @@ func TestRoomBuildICEServersNoSecret(t *testing.T) {
func TestRoomBuildICEServersMultipleTURN(t *testing.T) { func TestRoomBuildICEServersMultipleTURN(t *testing.T) {
cfg := testConfig() cfg := testConfig()
cfg.TURNServers = []TURNServerConfig{ cfg.TURNServers = []TURNServerConfig{
{Host: "1.2.3.4", Port: 3478}, {Host: "1.2.3.4", Port: 3478}, // non-secure → UDP + TCP = 2 URIs
{Host: "5.6.7.8", Port: 443}, {Host: "5.6.7.8", Port: 5349, Secure: true}, // secure → 1 URI
} }
rm := NewRoomManager(cfg, testLogger()) rm := NewRoomManager(cfg, testLogger())
@ -233,8 +236,9 @@ func TestRoomBuildICEServersMultipleTURN(t *testing.T) {
if len(servers) != 1 { if len(servers) != 1 {
t.Fatalf("ICE servers count = %d, want 1", len(servers)) t.Fatalf("ICE servers count = %d, want 1", len(servers))
} }
if len(servers[0].URLs) != 2 { // 1 non-secure (UDP+TCP) + 1 secure (TURNS) = 3 URIs
t.Fatalf("URLs count = %d, want 2", len(servers[0].URLs)) if len(servers[0].URLs) != 3 {
t.Fatalf("URLs count = %d, want 3", len(servers[0].URLs))
} }
} }

View File

@ -257,7 +257,12 @@ func (s *Server) sendTURNCredentials(peer *Peer) {
var uris []string var uris []string
for _, ts := range s.config.TURNServers { 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{ peer.SendMessage(NewServerMessage(MessageTypeTURNCredentials, &TURNCredentialsData{

View File

@ -233,7 +233,7 @@ func TestTURNCredentialsDataSerialization(t *testing.T) {
Username: "1234567890:test-ns", Username: "1234567890:test-ns",
Password: "base64password==", Password: "base64password==",
TTL: 600, 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) data, err := json.Marshal(creds)

View File

@ -10,9 +10,14 @@ type Config struct {
// ListenAddr is the address to bind the TURN listener (e.g., "0.0.0.0:3478") // ListenAddr is the address to bind the TURN listener (e.g., "0.0.0.0:3478")
ListenAddr string `yaml:"listen_addr"` ListenAddr string `yaml:"listen_addr"`
// TLSListenAddr is the address for TURN over TLS/DTLS (e.g., "0.0.0.0:443") // TURNSListenAddr is the address for TURNS (TURN over TLS on TCP, e.g., "0.0.0.0:5349")
// Uses UDP 443 — requires Caddy HTTP/3 (QUIC) to be disabled to avoid port conflict TURNSListenAddr string `yaml:"turns_listen_addr"`
TLSListenAddr string `yaml:"tls_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 is the public IP address of this node, advertised in TURN allocations
PublicIP string `yaml:"public_ip"` PublicIP string `yaml:"public_ip"`

View File

@ -3,6 +3,7 @@ package turn
import ( import (
"crypto/hmac" "crypto/hmac"
"crypto/sha1" "crypto/sha1"
"crypto/tls"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"net" "net"
@ -16,11 +17,12 @@ import (
// Server wraps a Pion TURN server with namespace-scoped HMAC-SHA1 authentication. // Server wraps a Pion TURN server with namespace-scoped HMAC-SHA1 authentication.
type Server struct { type Server struct {
config *Config config *Config
logger *zap.Logger logger *zap.Logger
turnServer *pionTurn.Server turnServer *pionTurn.Server
conn net.PacketConn // UDP listener on primary port (3478) conn net.PacketConn // UDP listener on primary port (3478)
tlsConn net.PacketConn // UDP listener on TLS port (443) 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. // 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 // Plain TCP listener on same port as UDP (3478) for TCP TURN fallback
// Requires Caddy HTTP/3 (QUIC) to be disabled to avoid UDP 443 conflict var listenerConfigs []pionTurn.ListenerConfig
if cfg.TLSListenAddr != "" { tcpListener, err := net.Listen("tcp", cfg.ListenAddr)
tlsConn, err := net.ListenPacket("udp4", cfg.TLSListenAddr) 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 { if err != nil {
conn.Close() 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{ listenerConfigs = append(listenerConfigs, pionTurn.ListenerConfig{
PacketConn: tlsConn, Listener: tlsListener,
RelayAddressGenerator: &pionTurn.RelayAddressGeneratorPortRange{ RelayAddressGenerator: &pionTurn.RelayAddressGeneratorPortRange{
RelayAddress: relayIP, RelayAddress: relayIP,
Address: "0.0.0.0", 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 // Create TURN server with HMAC-SHA1 auth
turnServer, err := pionTurn.NewServer(pionTurn.ServerConfig{ serverConfig := pionTurn.ServerConfig{
Realm: cfg.Realm, Realm: cfg.Realm,
AuthHandler: func(username, realm string, srcAddr net.Addr) ([]byte, bool) { AuthHandler: func(username, realm string, srcAddr net.Addr) ([]byte, bool) {
return s.authHandler(username, realm, srcAddr) return s.authHandler(username, realm, srcAddr)
}, },
PacketConnConfigs: packetConfigs, PacketConnConfigs: packetConfigs,
}) }
if len(listenerConfigs) > 0 {
serverConfig.ListenerConfigs = listenerConfigs
}
turnServer, err := pionTurn.NewServer(serverConfig)
if err != nil { if err != nil {
s.closeListeners() s.closeListeners()
return nil, fmt.Errorf("failed to create TURN server: %w", err) 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.turnServer = turnServer
s.logger.Info("TURN server started", s.logger.Info("TURN server started",
zap.String("listen_addr", cfg.ListenAddr), zap.String("listen_addr_udp", cfg.ListenAddr),
zap.String("tls_listen_addr", cfg.TLSListenAddr), zap.String("listen_addr_tcp", cfg.ListenAddr),
zap.String("turns_listen_addr", cfg.TURNSListenAddr),
zap.String("public_ip", cfg.PublicIP), zap.String("public_ip", cfg.PublicIP),
zap.String("realm", cfg.Realm), zap.String("realm", cfg.Realm),
zap.Int("relay_port_start", cfg.RelayPortStart), zap.Int("relay_port_start", cfg.RelayPortStart),
@ -178,9 +212,13 @@ func (s *Server) closeListeners() {
s.conn.Close() s.conn.Close()
s.conn = nil s.conn = nil
} }
if s.tlsConn != nil { if s.tcpListener != nil {
s.tlsConn.Close() s.tcpListener.Close()
s.tlsConn = nil s.tcpListener = nil
}
if s.tlsListener != nil {
s.tlsListener.Close()
s.tlsListener = nil
} }
} }

83
pkg/turn/tls.go Normal file
View File

@ -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
}