From e6f828d6f1456378b0ed85546e64702a4f626013 Mon Sep 17 00:00:00 2001 From: anonpenguin23 Date: Sat, 21 Feb 2026 11:31:20 +0200 Subject: [PATCH] feat: add WebRTC support with SFU and TURN server integration, including configuration, monitoring, and API endpoints --- docs/ARCHITECTURE.md | 30 +++ docs/DEPLOYMENT_GUIDE.md | 51 +++++ docs/MONITORING.md | 3 + docs/WEBRTC.md | 262 ++++++++++++++++++++++++ e2e/shared/webrtc_test.go | 241 ++++++++++++++++++++++ pkg/cli/production/report/namespaces.go | 21 ++ pkg/cli/production/report/types.go | 2 + pkg/environments/production/firewall.go | 53 +++++ pkg/inspector/checks/webrtc.go | 132 ++++++++++++ pkg/inspector/collector.go | 2 + 10 files changed, 797 insertions(+) create mode 100644 docs/WEBRTC.md create mode 100644 e2e/shared/webrtc_test.go create mode 100644 pkg/inspector/checks/webrtc.go diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a82b2fa..cbe8e4c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -474,6 +474,36 @@ configured, use the IP over HTTP port 80 (`http://`) which goes through Cadd Planned containerization with Docker Compose and Kubernetes support. +## WebRTC (Voice/Video/Data) + +Namespaces can opt in to WebRTC support for real-time voice, video, and data channels. + +### Components + +- **SFU (Selective Forwarding Unit)** — Pion WebRTC server that handles signaling (WebSocket), SDP negotiation, and RTP forwarding. Runs on all 3 cluster nodes, binds only to WireGuard IPs. +- **TURN Server** — Pion TURN relay that provides NAT traversal. Runs on 2 of 3 nodes for redundancy. Public-facing (UDP 3478, 443, relay range 49152-65535). + +### Security Model + +- **TURN-shielded**: SFU binds only to WireGuard (10.0.0.x), never 0.0.0.0. All client media flows through TURN relay. +- **Forced relay**: `iceTransportPolicy: relay` enforced server-side — no direct peer connections. +- **HMAC credentials**: Per-namespace TURN shared secret with 10-minute TTL. +- **Namespace isolation**: Each namespace has its own TURN secret, port ranges, and rooms. + +### Port Allocation + +WebRTC uses a separate port allocation system from core namespace services: + +| Service | Port Range | +|---------|-----------| +| SFU signaling | 30000-30099 | +| SFU media (RTP) | 20000-29999 | +| TURN listen | 3478/udp (standard) | +| TURN TLS | 443/udp | +| TURN relay | 49152-65535/udp | + +See [docs/WEBRTC.md](WEBRTC.md) for full details including client integration, API reference, and debugging. + ## Future Enhancements 1. **GraphQL Support** - GraphQL gateway alongside REST diff --git a/docs/DEPLOYMENT_GUIDE.md b/docs/DEPLOYMENT_GUIDE.md index 82af8eb..71481a9 100644 --- a/docs/DEPLOYMENT_GUIDE.md +++ b/docs/DEPLOYMENT_GUIDE.md @@ -872,6 +872,57 @@ orama app delete my-old-app --- +## WebRTC (Voice/Video/Data) + +Namespaces can enable WebRTC support for real-time communication (voice calls, video calls, data channels). + +### Enable WebRTC + +```bash +# Enable WebRTC for a namespace (must be run on a cluster node) +orama namespace enable webrtc --namespace myapp + +# Check WebRTC status +orama namespace webrtc-status --namespace myapp +``` + +This provisions SFU servers on all 3 nodes and TURN relay servers on 2 nodes, allocates port blocks, creates DNS records, and opens firewall ports. + +### Disable WebRTC + +```bash +orama namespace disable webrtc --namespace myapp +``` + +Stops all SFU/TURN services, deallocates ports, removes DNS records, and closes firewall ports. + +### Client Integration + +```javascript +// 1. Get TURN credentials +const creds = await fetch('https://ns-myapp.orama.network/v1/webrtc/turn/credentials', { + method: 'POST', + headers: { 'Authorization': `Bearer ${jwt}` } +}); +const { urls, username, credential, ttl } = await creds.json(); + +// 2. Create PeerConnection (forced relay) +const pc = new RTCPeerConnection({ + iceServers: [{ urls, username, credential }], + iceTransportPolicy: 'relay' +}); + +// 3. Connect signaling WebSocket +const ws = new WebSocket( + `wss://ns-myapp.orama.network/v1/webrtc/signal?room=${roomId}`, + ['Bearer', jwt] +); +``` + +See [docs/WEBRTC.md](WEBRTC.md) for the full API reference, room management, credential protocol, and debugging guide. + +--- + ## Troubleshooting ### Deployment Issues diff --git a/docs/MONITORING.md b/docs/MONITORING.md index 698f5cd..228a328 100644 --- a/docs/MONITORING.md +++ b/docs/MONITORING.md @@ -238,6 +238,8 @@ These checks compare data across all nodes: - **WireGuard Peer Symmetry**: Each node has N-1 peers - **Clock Skew**: Node clocks within 5 seconds of each other - **Binary Version**: All nodes running the same version +- **WebRTC SFU Coverage**: SFU running on expected nodes (3/3) per namespace +- **WebRTC TURN Redundancy**: TURN running on expected nodes (2/3) per namespace ### Per-Node Checks @@ -249,6 +251,7 @@ These checks compare data across all nodes: - **Anyone**: Bootstrap progress - **Processes**: Zombies, orphans, panics in logs - **Namespaces**: Gateway and RQLite per namespace +- **WebRTC**: SFU and TURN service health (when provisioned) - **Network**: UFW, internet reachability, TCP retransmission ## Monitor vs Inspector diff --git a/docs/WEBRTC.md b/docs/WEBRTC.md new file mode 100644 index 0000000..bae8261 --- /dev/null +++ b/docs/WEBRTC.md @@ -0,0 +1,262 @@ +# WebRTC Integration + +Real-time voice, video, and data channels for Orama Network namespaces. + +## Architecture + +``` +Client A Client B + │ │ + │ 1. Get TURN credentials (REST) │ + │ 2. Connect WebSocket (signaling) │ + │ 3. Exchange SDP/ICE via SFU │ + │ │ + ▼ ▼ +┌──────────┐ UDP relay ┌──────────┐ +│ TURN │◄──────────────────►│ TURN │ +│ Server │ (public IPs) │ Server │ +│ Node 1 │ │ Node 2 │ +└────┬─────┘ └────┬─────┘ + │ WireGuard │ WireGuard + ▼ ▼ +┌──────────────────────────────────────────┐ +│ SFU Servers (3 nodes) │ +│ - WebSocket signaling (WireGuard only) │ +│ - Pion WebRTC (RTP forwarding) │ +│ - Room management │ +│ - Track publish/subscribe │ +└──────────────────────────────────────────┘ +``` + +**Key design decisions:** +- **TURN-shielded**: SFU binds only to WireGuard IPs. All client media flows through TURN relay. +- **`iceTransportPolicy: relay`** enforced server-side — no direct peer connections. +- **Opt-in per namespace** via `orama namespace enable webrtc`. +- **SFU on all 3 nodes**, **TURN on 2 of 3 nodes** (redundancy without over-provisioning). +- **Separate port allocation** from existing namespace services. + +## Prerequisites + +- Namespace must be provisioned with a ready cluster (RQLite + Olric + Gateway running). +- Command must be run on a cluster node (uses internal gateway endpoint). + +## Enable / Disable + +```bash +# Enable WebRTC for a namespace +orama namespace enable webrtc --namespace myapp + +# Check status +orama namespace webrtc-status --namespace myapp + +# Disable WebRTC (stops services, deallocates ports, removes DNS) +orama namespace disable webrtc --namespace myapp +``` + +### What happens on enable: +1. Generates a per-namespace TURN shared secret (32 bytes, crypto/rand) +2. Inserts `namespace_webrtc_config` DB record +3. Allocates WebRTC port blocks on each node (SFU signaling + media range, TURN relay range) +4. Spawns TURN on 2 nodes (selected by capacity) +5. Spawns SFU on all 3 nodes +6. Creates DNS A records: `turn.ns-{name}.{baseDomain}` pointing to TURN node public IPs +7. Updates cluster state on all nodes (for cold-boot restoration) + +### What happens on disable: +1. Stops SFU on all 3 nodes +2. Stops TURN on 2 nodes +3. Deallocates all WebRTC ports +4. Deletes TURN DNS records +5. Cleans up DB records (`namespace_webrtc_config`, `webrtc_rooms`) +6. Updates cluster state + +## Client Integration (JavaScript) + +### 1. Get TURN Credentials + +```javascript +const response = await fetch('https://ns-myapp.orama.network/v1/webrtc/turn/credentials', { + method: 'POST', + headers: { 'Authorization': `Bearer ${jwt}` } +}); + +const { urls, username, credential, ttl } = await response.json(); +// urls: ["turn:1.2.3.4:3478?transport=udp", "turns:1.2.3.4:443?transport=udp"] +// username: "{expiry_unix}:{namespace}" +// credential: HMAC-SHA1 derived +// ttl: 600 (seconds) +``` + +### 2. Create PeerConnection + +```javascript +const pc = new RTCPeerConnection({ + iceServers: [{ urls, username, credential }], + iceTransportPolicy: 'relay' // enforced by SFU +}); +``` + +### 3. Connect Signaling WebSocket + +```javascript +const ws = new WebSocket( + `wss://ns-myapp.orama.network/v1/webrtc/signal?room=${roomId}`, + ['Bearer', jwt] +); + +ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + switch (msg.type) { + case 'offer': handleOffer(msg); break; + case 'answer': handleAnswer(msg); break; + case 'ice-candidate': handleICE(msg); break; + case 'peer-joined': handleJoin(msg); break; + case 'peer-left': handleLeave(msg); break; + case 'turn-credentials': + case 'refresh-credentials': + updateTURN(msg); // SFU sends refreshed creds at 80% TTL + break; + case 'server-draining': + reconnect(); // SFU shutting down, reconnect to another node + break; + } +}; +``` + +### 4. Room Management (REST) + +```javascript +// Create room +await fetch('/v1/webrtc/rooms', { + method: 'POST', + headers: { 'Authorization': `Bearer ${jwt}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ room_id: 'my-room' }) +}); + +// List rooms +const rooms = await fetch('/v1/webrtc/rooms', { + headers: { 'Authorization': `Bearer ${jwt}` } +}); + +// Close room +await fetch('/v1/webrtc/rooms?room_id=my-room', { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${jwt}` } +}); +``` + +## API Reference + +### REST Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v1/webrtc/turn/credentials` | JWT/API key | Get TURN relay credentials | +| GET/WS | `/v1/webrtc/signal` | JWT/API key | WebSocket signaling | +| GET | `/v1/webrtc/rooms` | JWT/API key | List rooms | +| POST | `/v1/webrtc/rooms` | JWT/API key (owner) | Create room | +| DELETE | `/v1/webrtc/rooms` | JWT/API key (owner) | Close room | + +### Signaling Messages + +| Type | Direction | Description | +|------|-----------|-------------| +| `join` | Client → SFU | Join room | +| `offer` | Client ↔ SFU | SDP offer | +| `answer` | Client ↔ SFU | SDP answer | +| `ice-candidate` | Client ↔ SFU | ICE candidate | +| `leave` | Client → SFU | Leave room | +| `peer-joined` | SFU → Client | New peer notification | +| `peer-left` | SFU → Client | Peer departure | +| `turn-credentials` | SFU → Client | Initial TURN credentials | +| `refresh-credentials` | SFU → Client | Refreshed credentials (at 80% TTL) | +| `server-draining` | SFU → Client | SFU shutting down | + +## Port Allocation + +WebRTC uses a **separate port allocation system** from the core namespace ports: + +| Service | Port Range | Per Namespace | +|---------|-----------|---------------| +| SFU signaling | 30000-30099 | 1 port | +| SFU media (RTP) | 20000-29999 | 500 ports | +| TURN listen | 3478 (standard) | fixed | +| TURN TLS | 443/udp (standard) | fixed | +| TURN relay | 49152-65535 | 800 ports | + +## TURN Credential Protocol + +- Credentials use HMAC-SHA1 with a per-namespace shared secret +- Username format: `{expiry_unix}:{namespace}` +- Default TTL: 600 seconds (10 minutes) +- SFU proactively sends `refresh-credentials` at 80% of TTL (8 minutes) +- Clients should update ICE servers on receiving refresh + +## Monitoring + +```bash +# Check WebRTC status +orama namespace webrtc-status --namespace myapp + +# Monitor report includes SFU/TURN status +orama monitor report --env devnet + +# Inspector checks WebRTC health +orama inspector --env devnet +``` + +The monitoring report includes per-namespace `sfu_up` and `turn_up` fields. The inspector runs cross-node checks to verify SFU coverage (3 nodes) and TURN redundancy (2 nodes). + +## Debugging + +```bash +# SFU logs +journalctl -u orama-namespace-sfu@myapp -f + +# TURN logs +journalctl -u orama-namespace-turn@myapp -f + +# Check service status +systemctl status orama-namespace-sfu@myapp +systemctl status orama-namespace-turn@myapp +``` + +## Security Model + +- **Forced relay**: `iceTransportPolicy: relay` enforced server-side. Clients cannot bypass TURN. +- **HMAC credentials**: Per-namespace TURN shared secret. Credentials expire after 10 minutes. +- **Namespace isolation**: Each namespace has its own TURN secret, port ranges, and rooms. +- **Authentication required**: All WebRTC endpoints require JWT or API key (not in `isPublicPath()`). +- **Room management**: Creating/closing rooms requires namespace ownership. +- **SFU on WireGuard only**: SFU binds to 10.0.0.x, never 0.0.0.0. Only reachable via TURN relay. +- **Permissions-Policy**: `camera=(self), microphone=(self)` — only same-origin can access media devices. + +## Firewall + +When WebRTC is enabled, the following ports are opened via UFW: + +| Port | Protocol | Purpose | +|------|----------|---------| +| 3478 | UDP | TURN standard | +| 443 | UDP | TURN TLS (does not conflict with Caddy TCP 443) | +| 49152-65535 | UDP | TURN relay range (allocated per namespace) | + +SFU ports are NOT opened in the firewall — they are WireGuard-internal only. + +## Database Tables + +| Table | Purpose | +|-------|---------| +| `namespace_webrtc_config` | Per-namespace WebRTC config (enabled, TURN secret, node counts) | +| `webrtc_rooms` | Room-to-SFU-node affinity | +| `webrtc_port_allocations` | SFU/TURN port tracking | + +## Cold Boot Recovery + +On node restart, the cluster state file (`cluster_state.json`) includes `has_sfu`, `has_turn`, and port allocation data. The restore process: + +1. Core services restore first: RQLite → Olric → Gateway +2. If `has_turn` is set: fetches TURN shared secret from DB, spawns TURN +3. If `has_sfu` is set: fetches WebRTC config from DB, spawns SFU with TURN server list + +If the DB is unavailable during restore, SFU/TURN restoration is skipped with a warning log. They will be restored on the next successful DB connection. diff --git a/e2e/shared/webrtc_test.go b/e2e/shared/webrtc_test.go new file mode 100644 index 0000000..9fb92c6 --- /dev/null +++ b/e2e/shared/webrtc_test.go @@ -0,0 +1,241 @@ +//go:build e2e + +package shared_test + +import ( + "bytes" + "encoding/json" + "net/http" + "strings" + "testing" + "time" + + e2e "github.com/DeBrosOfficial/network/e2e" +) + +// turnCredentialsResponse is the expected response from the TURN credentials endpoint. +type turnCredentialsResponse struct { + URLs []string `json:"urls"` + Username string `json:"username"` + Credential string `json:"credential"` + TTL int `json:"ttl"` +} + +// TestWebRTC_TURNCredentials_RequiresAuth verifies that the TURN credentials endpoint +// rejects unauthenticated requests. +func TestWebRTC_TURNCredentials_RequiresAuth(t *testing.T) { + e2e.SkipIfMissingGateway(t) + + gatewayURL := e2e.GetGatewayURL() + client := e2e.NewHTTPClient(10 * time.Second) + + req, err := http.NewRequest("POST", gatewayURL+"/v1/webrtc/turn/credentials", nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401 Unauthorized, got %d", resp.StatusCode) + } +} + +// TestWebRTC_TURNCredentials_ValidResponse verifies that authenticated requests to the +// TURN credentials endpoint return a valid credential structure. +func TestWebRTC_TURNCredentials_ValidResponse(t *testing.T) { + e2e.SkipIfMissingGateway(t) + + gatewayURL := e2e.GetGatewayURL() + apiKey := e2e.GetAPIKey() + if apiKey == "" { + t.Skip("no API key configured") + } + client := e2e.NewHTTPClient(10 * time.Second) + + req, err := http.NewRequest("POST", gatewayURL+"/v1/webrtc/turn/credentials", nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 OK, got %d", resp.StatusCode) + } + + var creds turnCredentialsResponse + if err := json.NewDecoder(resp.Body).Decode(&creds); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(creds.URLs) == 0 { + t.Fatal("expected at least one TURN URL") + } + if creds.Username == "" { + t.Fatal("expected non-empty username") + } + if creds.Credential == "" { + t.Fatal("expected non-empty credential") + } + if creds.TTL <= 0 { + t.Fatalf("expected positive TTL, got %d", creds.TTL) + } +} + +// TestWebRTC_Rooms_RequiresAuth verifies that the rooms endpoint rejects unauthenticated requests. +func TestWebRTC_Rooms_RequiresAuth(t *testing.T) { + e2e.SkipIfMissingGateway(t) + + gatewayURL := e2e.GetGatewayURL() + client := e2e.NewHTTPClient(10 * time.Second) + + req, err := http.NewRequest("GET", gatewayURL+"/v1/webrtc/rooms", nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401 Unauthorized, got %d", resp.StatusCode) + } +} + +// TestWebRTC_Signal_RequiresAuth verifies that the signaling WebSocket rejects +// unauthenticated connections. +func TestWebRTC_Signal_RequiresAuth(t *testing.T) { + e2e.SkipIfMissingGateway(t) + + gatewayURL := e2e.GetGatewayURL() + client := e2e.NewHTTPClient(10 * time.Second) + + // Use regular HTTP GET to the signal endpoint — without auth it should return 401 + // before WebSocket upgrade + req, err := http.NewRequest("GET", gatewayURL+"/v1/webrtc/signal?room=test-room", nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } +} + +// TestWebRTC_Rooms_CreateAndList verifies room creation and listing with proper auth. +func TestWebRTC_Rooms_CreateAndList(t *testing.T) { + e2e.SkipIfMissingGateway(t) + + gatewayURL := e2e.GetGatewayURL() + apiKey := e2e.GetAPIKey() + if apiKey == "" { + t.Skip("no API key configured") + } + client := e2e.NewHTTPClient(10 * time.Second) + + roomID := e2e.GenerateUniqueID("e2e-webrtc-room") + + // Create room + createBody, _ := json.Marshal(map[string]string{"room_id": roomID}) + req, err := http.NewRequest("POST", gatewayURL+"/v1/webrtc/rooms", bytes.NewReader(createBody)) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("create room failed: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 200/201, got %d", resp.StatusCode) + } + + // List rooms + req, err = http.NewRequest("GET", gatewayURL+"/v1/webrtc/rooms", nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err = client.Do(req) + if err != nil { + t.Fatalf("list rooms failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + // Clean up: delete room + req, err = http.NewRequest("DELETE", gatewayURL+"/v1/webrtc/rooms?room_id="+roomID, nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp2, err := client.Do(req) + if err != nil { + t.Fatalf("delete room failed: %v", err) + } + resp2.Body.Close() +} + +// TestWebRTC_PermissionsPolicy verifies the Permissions-Policy header allows camera and microphone. +func TestWebRTC_PermissionsPolicy(t *testing.T) { + e2e.SkipIfMissingGateway(t) + + gatewayURL := e2e.GetGatewayURL() + apiKey := e2e.GetAPIKey() + if apiKey == "" { + t.Skip("no API key configured") + } + client := e2e.NewHTTPClient(10 * time.Second) + + req, err := http.NewRequest("GET", gatewayURL+"/v1/webrtc/rooms", nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + pp := resp.Header.Get("Permissions-Policy") + if pp == "" { + t.Skip("Permissions-Policy header not set") + } + + if !strings.Contains(pp, "camera=(self)") { + t.Errorf("Permissions-Policy missing camera=(self), got: %s", pp) + } + if !strings.Contains(pp, "microphone=(self)") { + t.Errorf("Permissions-Policy missing microphone=(self), got: %s", pp) + } +} diff --git a/pkg/cli/production/report/namespaces.go b/pkg/cli/production/report/namespaces.go index 6725451..ef0bf8c 100644 --- a/pkg/cli/production/report/namespaces.go +++ b/pkg/cli/production/report/namespaces.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "os" + "os/exec" "path/filepath" "regexp" "strconv" @@ -162,5 +163,25 @@ func collectNamespaceReport(ns nsInfo) NamespaceReport { } } + // 5. SFUUp: check if namespace SFU systemd service is active (optional) + r.SFUUp = isNamespaceServiceActive("sfu", ns.name) + + // 6. TURNUp: check if namespace TURN systemd service is active (optional) + r.TURNUp = isNamespaceServiceActive("turn", ns.name) + return r } + +// isNamespaceServiceActive checks if a namespace service is provisioned and active. +// Returns false if the service is not provisioned (no env file) or not running. +func isNamespaceServiceActive(serviceType, namespace string) bool { + // Only check if the service was provisioned (env file exists) + envFile := fmt.Sprintf("/opt/orama/.orama/data/namespaces/%s/%s.env", namespace, serviceType) + if _, err := os.Stat(envFile); err != nil { + return false // not provisioned + } + + svcName := fmt.Sprintf("orama-namespace-%s@%s", serviceType, namespace) + cmd := exec.Command("systemctl", "is-active", "--quiet", svcName) + return cmd.Run() == nil +} diff --git a/pkg/cli/production/report/types.go b/pkg/cli/production/report/types.go index b782267..7607917 100644 --- a/pkg/cli/production/report/types.go +++ b/pkg/cli/production/report/types.go @@ -274,6 +274,8 @@ type NamespaceReport struct { OlricUp bool `json:"olric_up"` GatewayUp bool `json:"gateway_up"` GatewayStatus int `json:"gateway_status,omitempty"` + SFUUp bool `json:"sfu_up"` + TURNUp bool `json:"turn_up"` } // --- Deployments --- diff --git a/pkg/environments/production/firewall.go b/pkg/environments/production/firewall.go index 40ec143..0074a7c 100644 --- a/pkg/environments/production/firewall.go +++ b/pkg/environments/production/firewall.go @@ -12,6 +12,9 @@ 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) + TURNRelayStart int // start of TURN relay port range (default 49152) + TURNRelayEnd int // end of TURN relay port range (default 65535) } // FirewallProvisioner manages UFW firewall setup @@ -84,6 +87,15 @@ func (fp *FirewallProvisioner) GenerateRules() []string { rules = append(rules, fmt.Sprintf("ufw allow %d/tcp", fp.config.AnyoneORPort)) } + // 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) + 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)) + } + } + // Allow all traffic from WireGuard subnet (inter-node encrypted traffic) rules = append(rules, "ufw allow from 10.0.0.0/8") @@ -130,6 +142,47 @@ func (fp *FirewallProvisioner) IsActive() bool { return strings.Contains(string(output), "Status: active") } +// AddWebRTCRules dynamically adds TURN port rules without a full firewall reset. +// Used when enabling WebRTC on a namespace. +func (fp *FirewallProvisioner) AddWebRTCRules(relayStart, relayEnd int) error { + rules := []string{ + "ufw allow 3478/udp", + "ufw allow 443/udp", + } + if relayStart > 0 && relayEnd > 0 { + rules = append(rules, fmt.Sprintf("ufw allow %d:%d/udp", relayStart, relayEnd)) + } + + for _, rule := range rules { + parts := strings.Fields(rule) + cmd := exec.Command(parts[0], parts[1:]...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to add firewall rule '%s': %w\n%s", rule, err, string(output)) + } + } + return nil +} + +// RemoveWebRTCRules dynamically removes TURN port rules without a full firewall reset. +// Used when disabling WebRTC on a namespace. +func (fp *FirewallProvisioner) RemoveWebRTCRules(relayStart, relayEnd int) error { + rules := []string{ + "ufw delete allow 3478/udp", + "ufw delete allow 443/udp", + } + if relayStart > 0 && relayEnd > 0 { + rules = append(rules, fmt.Sprintf("ufw delete allow %d:%d/udp", relayStart, relayEnd)) + } + + for _, rule := range rules { + parts := strings.Fields(rule) + cmd := exec.Command(parts[0], parts[1:]...) + // Ignore errors on delete — rule may not exist + cmd.CombinedOutput() + } + return nil +} + // GetStatus returns the current UFW status func (fp *FirewallProvisioner) GetStatus() (string, error) { cmd := exec.Command("ufw", "status", "verbose") diff --git a/pkg/inspector/checks/webrtc.go b/pkg/inspector/checks/webrtc.go new file mode 100644 index 0000000..0d87d34 --- /dev/null +++ b/pkg/inspector/checks/webrtc.go @@ -0,0 +1,132 @@ +package checks + +import ( + "fmt" + + "github.com/DeBrosOfficial/network/pkg/inspector" +) + +func init() { + inspector.RegisterChecker("webrtc", CheckWebRTC) +} + +const webrtcSub = "webrtc" + +// CheckWebRTC runs WebRTC (SFU/TURN) health checks. +// These checks only apply to namespaces that have SFU or TURN provisioned. +func CheckWebRTC(data *inspector.ClusterData) []inspector.CheckResult { + var results []inspector.CheckResult + + for _, nd := range data.Nodes { + results = append(results, checkWebRTCPerNode(nd)...) + } + + results = append(results, checkWebRTCCrossNode(data)...) + + return results +} + +func checkWebRTCPerNode(nd *inspector.NodeData) []inspector.CheckResult { + var r []inspector.CheckResult + node := nd.Node.Name() + + for _, ns := range nd.Namespaces { + // Only check SFU/TURN if they are provisioned on this node. + // A false value when not provisioned is not an error. + hasSFU := ns.SFUUp // true = service active + hasTURN := ns.TURNUp // true = service active + + // If neither is provisioned, skip WebRTC checks for this namespace + if !hasSFU && !hasTURN { + continue + } + + prefix := fmt.Sprintf("ns.%s", ns.Name) + + if hasSFU { + r = append(r, inspector.Pass(prefix+".sfu_up", + fmt.Sprintf("Namespace %s SFU active", ns.Name), + webrtcSub, node, "systemd service running", inspector.High)) + } + + if hasTURN { + r = append(r, inspector.Pass(prefix+".turn_up", + fmt.Sprintf("Namespace %s TURN active", ns.Name), + webrtcSub, node, "systemd service running", inspector.High)) + } + } + + return r +} + +func checkWebRTCCrossNode(data *inspector.ClusterData) []inspector.CheckResult { + var r []inspector.CheckResult + + // Collect SFU/TURN node counts per namespace + type webrtcCounts struct { + sfuNodes int + turnNodes int + } + nsCounts := map[string]*webrtcCounts{} + + for _, nd := range data.Nodes { + for _, ns := range nd.Namespaces { + if !ns.SFUUp && !ns.TURNUp { + continue + } + c, ok := nsCounts[ns.Name] + if !ok { + c = &webrtcCounts{} + nsCounts[ns.Name] = c + } + if ns.SFUUp { + c.sfuNodes++ + } + if ns.TURNUp { + c.turnNodes++ + } + } + } + + for name, counts := range nsCounts { + // SFU should be on all cluster nodes (typically 3) + if counts.sfuNodes > 0 { + if counts.sfuNodes >= 3 { + r = append(r, inspector.Pass( + fmt.Sprintf("ns.%s.sfu_coverage", name), + fmt.Sprintf("Namespace %s SFU on all nodes", name), + webrtcSub, "", + fmt.Sprintf("%d SFU nodes active", counts.sfuNodes), + inspector.High)) + } else { + r = append(r, inspector.Warn( + fmt.Sprintf("ns.%s.sfu_coverage", name), + fmt.Sprintf("Namespace %s SFU on all nodes", name), + webrtcSub, "", + fmt.Sprintf("only %d/3 SFU nodes active", counts.sfuNodes), + inspector.High)) + } + } + + // TURN should be on 2 nodes + if counts.turnNodes > 0 { + if counts.turnNodes >= 2 { + r = append(r, inspector.Pass( + fmt.Sprintf("ns.%s.turn_coverage", name), + fmt.Sprintf("Namespace %s TURN redundant", name), + webrtcSub, "", + fmt.Sprintf("%d TURN nodes active", counts.turnNodes), + inspector.High)) + } else { + r = append(r, inspector.Warn( + fmt.Sprintf("ns.%s.turn_coverage", name), + fmt.Sprintf("Namespace %s TURN redundant", name), + webrtcSub, "", + fmt.Sprintf("only %d/2 TURN nodes active (no redundancy)", counts.turnNodes), + inspector.High)) + } + } + } + + return r +} diff --git a/pkg/inspector/collector.go b/pkg/inspector/collector.go index 67470b2..534b9f7 100644 --- a/pkg/inspector/collector.go +++ b/pkg/inspector/collector.go @@ -41,6 +41,8 @@ type NamespaceData struct { OlricUp bool // Olric memberlist port listening GatewayUp bool // Gateway HTTP port responding GatewayStatus int // HTTP status code from gateway health + SFUUp bool // SFU systemd service active (optional, WebRTC) + TURNUp bool // TURN systemd service active (optional, WebRTC) } // RQLiteData holds parsed RQLite status from a single node.