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
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)'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"])
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 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"`

View File

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

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
}