Validate API keys against the gateway's OWN namespace RQLite instead of the core cluster. `orama namespace keys create` writes keys to the namespace DB (:10000), but validation read the core DB (:5001), so every namespace key returned 401. Auth now binds explicitly to the namespace-bound handle via a narrow apiKeyQuerier interface, and the duplicate core-bound lookup in jwt_handler.go — the cause of POST /v1/auth/token 401s — uses the same handle. Underlying cause is that client.DefaultClientConfig pre-populates DatabaseEndpoints from bootstrap peers on port 5001, so the `if len(...) == 0` override in dependencies.go silently discards rqlite_dsn. That default is left alone here: g.client feeds 26 call sites (ACME, domains, vault, push), so flipping it needs its own change with each consumer audited. Also in this release: - WebRTC role reconciliation: node replacement left turn/sfu allocations on the departed node. Allocation-sourced ports, spawn gating, 60s reconciler. - TURNS certs: single-label turn-<ns> host so the LE wildcard actually covers it, plus a warm ReconcileTURN patch. SFU media_port=0 crash-loop fixed. - DNS: purge inactive TURN/namespace host records, retract foreign records. - Serverless: nested invokes from system parents carry the internal trigger type. - Storage: evict handler, unpin idempotency. API-key auth verified end-to-end on devnet 0.122.97: token exchange returns 200 with correct namespace and scope claims; bogus keys still 401. Full suite green. Co-Authored-By: Claude <noreply@anthropic.com>
14 KiB
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: relayenforced 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
# 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:
- Generates a per-namespace TURN shared secret (32 bytes, crypto/rand)
- Inserts
namespace_webrtc_configDB record - Allocates WebRTC port blocks on each node (SFU signaling + media range, TURN relay range)
- Spawns TURN on 2 nodes (selected by capacity)
- Spawns SFU on all 3 nodes
- Creates DNS A records pointing to TURN node public IPs:
turn.ns-{name}.{baseDomain}(plain UDP/TCP TURN) andturn-{name}.{baseDomain}(single-label TLS host for TURNS, covered by the*.{baseDomain}wildcard cert) - Updates cluster state on all nodes (for cold-boot restoration)
What happens on disable:
- Stops SFU on all 3 nodes
- Stops TURN on 2 nodes
- Deallocates all WebRTC ports
- Deletes TURN DNS records
- Cleans up DB records (
namespace_webrtc_config,webrtc_rooms) - Updates cluster state
Client Integration (JavaScript)
Authentication
All WebRTC endpoints require authentication. Use one of:
# Option A: API Key via header (recommended)
X-API-Key: <your-namespace-api-key>
# Option B: API Key via Authorization header
Authorization: ApiKey <your-namespace-api-key>
# Option C: JWT Bearer token
Authorization: Bearer <jwt>
1. Get TURN Credentials
const response = await fetch('https://ns-myapp.orama-devnet.network/v1/webrtc/turn/credentials', {
method: 'POST',
headers: { 'X-API-Key': apiKey }
});
const { uris, username, password, ttl } = await response.json();
// uris: [
// "turn:turn.ns-myapp.orama-devnet.network:3478?transport=udp",
// "turn:turn.ns-myapp.orama-devnet.network:3478?transport=tcp",
// "turns:turn-myapp.orama-devnet.network:5349"
// ]
// NOTE: plain UDP/TCP TURN uses the two-label host turn.ns-<ns>.<base>; TURNS
// (TLS) uses the SINGLE-label host turn-<ns>.<base>. Only a single-label host
// is covered by the *.<base> wildcard cert, so only it validates in browsers —
// the two-label host can present a self-signed cert only, which browsers reject.
// Both round-robin to the same TURN nodes.
// username: "{expiry_unix}:{namespace}"
// password: HMAC-SHA1 derived (base64)
// ttl: 86400 (seconds — 24h; the one-shot REST/host-fn credential is not
// refreshed mid-call, so it must outlast any call, bugboard #155)
2. Create PeerConnection
const pc = new RTCPeerConnection({
iceServers: [{ urls: uris, username, credential: password }],
iceTransportPolicy: 'relay' // enforced by SFU
});
3. Connect Signaling WebSocket
const ws = new WebSocket(
`wss://ns-myapp.orama-devnet.network/v1/webrtc/signal?room=${roomId}&api_key=${apiKey}`
);
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)
const headers = { 'X-API-Key': apiKey, 'Content-Type': 'application/json' };
// Create room
await fetch('/v1/webrtc/rooms', {
method: 'POST',
headers,
body: JSON.stringify({ room_id: 'my-room' })
});
// List rooms
const rooms = await fetch('/v1/webrtc/rooms', { headers });
// Close room
await fetch('/v1/webrtc/rooms?room_id=my-room', {
method: 'DELETE',
headers
});
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 | Protocol | Per Namespace |
|---|---|---|---|
| SFU signaling | 30000-30099 | TCP (WireGuard only) | 1 port |
| SFU media (RTP) | 20000-29999 | UDP (WireGuard only) | 500 ports |
| TURN listen | 3478 | UDP + TCP | fixed |
| TURNS (TLS) | 5349 | TCP | fixed |
| TURN relay | 49152-65535 | UDP | 800 ports |
TURN Credential Protocol
- Credentials use HMAC-SHA1 with a per-namespace shared secret
- Username format:
{expiry_unix}:{namespace} - Password:
base64(HMAC-SHA1(shared_secret, username)) - One-shot REST /
turn_credentialshost-fn TTL: 24h (turn.DefaultCredentialTTL). These paths mint once at call setup and are never refreshed, so the credential must outlast the whole call — a short TTL tore down relay-only media at expiry (bugboard #155). - SFU signaling path TTL: per-namespace
turn_credential_ttl(default 600s). The SFU proactively sendsrefresh-credentialsover the signaling WebSocket at 80% of TTL, so a short TTL is safe there. - Clients should update ICE servers on receiving refresh
TURNS TLS Certificate
TURNS (port 5349) uses TLS and the client connects to the single-label host
turn-{name}.{baseDomain}. Certificate provisioning, in order:
- Wildcard reuse (primary): TURN presents Caddy's existing
*.{baseDomain}wildcard cert (already provisioned for HTTPS). The single-label TLS host is covered by it, so no per-namespace ACME provisioning is needed and browsers validate the cert. Theorama-nodeservice reads the wildcard from Caddy's storage; the cert reloader hot-reloads renewals. - Per-domain Let's Encrypt (fallback): If the wildcard is unavailable, TURN
tries to provision a per-domain cert by appending to the Caddyfile. This path
fails on nodes where
orama-noderunsProtectSystem=strict(can't write/etc/caddy), so it is best-effort only. - Self-signed (last resort): If neither works, a self-signed cert is
generated with the node's public IP as SAN. Browsers reject it — TURNS is
effectively unavailable until a valid cert is in place. The two-label host
turn.ns-{name}.{baseDomain}can only reach this state (the wildcard doesn't cover it), which is why TURNS moved to the single-label host.
Caddy auto-renews Let's Encrypt certs at ~60 days. TURN serves the cert through a hot-reloading GetCertificate callback that polls the cert file every 60 seconds, so renewed certs are picked up in-process without a restart (a restart would drop every active relay).
Role Reconciliation
TURN and SFU roles are recorded in webrtc_port_allocations — that table, not any
local file, is the authority for which node runs what. Every node runs a 60s
reconciler that keeps reality matching it:
| Step | What it does |
|---|---|
| Reallocate | One node per sweep (the lowest-sorted live member, elected deterministically with no lock) drops roles held by nodes that have left the cluster and assigns them to current members. Requires a strict majority to act, so a partitioned minority can never reshape roles. |
| Start | Starts TURN/SFU this node holds an allocation for but is not running. Backs off for 10 minutes after a failed start, so a crash-looping unit is not restarted every tick. |
| Stop | Stops TURN/SFU this node no longer holds — but only on a clean allocator read that returns nothing. An unreadable database means do nothing, never stop. |
| Advertise | Re-adds this node's TURN DNS records, but only when it both holds the allocation and is actually serving. |
Two properties are deliberate:
- Revocation follows cluster membership, not heartbeats. A node that misses a heartbeat keeps its roles; only leaving the cluster releases them. A 120-second heartbeat gap must never move a relay.
- Stopping requires positive evidence. Starting a service is backed off and can fail; stopping is immediate. A reconciler whose stop path is more capable than its start path can only ever reduce capacity, so the stop path is the conservative one.
Without this, replacing a node left its TURN/SFU roles behind: the namespace kept two TURN allocations where one belonged to a machine that no longer existed, and the replacement node held no role at all (bugboard #161).
Monitoring
# 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
# 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: relayenforced server-side. Clients cannot bypass TURN. - HMAC credentials: Per-namespace TURN shared secret. REST/host-fn credentials expire after 24h (long enough to outlast any call, since they are not refreshed mid-call); SFU-signaled credentials use the shorter per-namespace TTL and are refreshed over the signaling channel.
- Namespace isolation: Each namespace has its own TURN secret, port ranges, and rooms.
- Authentication required: All WebRTC endpoints require API key or JWT (
X-API-Keyheader,Authorization: ApiKey, orAuthorization: Bearer). - 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 on TURN nodes:
| Port | Protocol | Purpose |
|---|---|---|
| 3478 | UDP | TURN standard |
| 3478 | TCP | TURN TCP fallback (for clients behind UDP-blocking firewalls) |
| 5349 | TCP | TURNS — TURN over TLS (encrypted, works through strict firewalls/DPI) |
| 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:
- Core services restore first: RQLite → Olric → Gateway
- If
has_turnis set: fetches TURN shared secret from DB, spawns TURN - If
has_sfuis 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.