mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-03-17 05:13:01 +00:00
feat: add WebRTC support with SFU and TURN server integration, including configuration, monitoring, and API endpoints
This commit is contained in:
parent
8ee606bfb1
commit
e6f828d6f1
@ -474,6 +474,36 @@ configured, use the IP over HTTP port 80 (`http://<ip>`) 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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
262
docs/WEBRTC.md
Normal file
262
docs/WEBRTC.md
Normal file
@ -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.
|
||||
241
e2e/shared/webrtc_test.go
Normal file
241
e2e/shared/webrtc_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 ---
|
||||
|
||||
@ -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")
|
||||
|
||||
132
pkg/inspector/checks/webrtc.go
Normal file
132
pkg/inspector/checks/webrtc.go
Normal file
@ -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
|
||||
}
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user