From f1dbcd0f867ec8a73592876561e87e86ea02d2e2 Mon Sep 17 00:00:00 2001 From: sravan Date: Mon, 6 Jul 2026 13:11:37 +0530 Subject: [PATCH] =?UTF-8?q?feat(meetings):=20LiveKit=20SFU=20=E2=80=94=20p?= =?UTF-8?q?hase=201=20(server=20plumbing,=20config-gated)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the server side of scaling meetings past the ~5-peer mesh: - config.js: LIVEKIT_URL/API_KEY/API_SECRET + LIVEKIT_ENABLED flag. All optional; when unset the app keeps the built-in P2P mesh (fully additive, like push). - routes.js: GET /api/meetings/config (tells the client sfu on/off + wss url) and POST /api/meetings/token (mints a per-user, per-room LiveKit join token — hand-rolled HS256 JWT like the FCM/APNs tokens, no new dependency; secret stays server-side). - docker-compose.yml: optional livekit service (single-node, no Redis), keys injected via LIVEKIT_KEYS from the same .env; media over published UDP 50000-50100 + TCP 7881, signaling proxied by NPM. - livekit.yaml + .env.example documented. Client (mesh->LiveKit media swap, behind the flag) lands in phase 2. Co-Authored-By: Claude Opus 4.8 --- .env.example | 8 ++++++++ docker-compose.yml | 20 ++++++++++++++++++++ livekit.yaml | 21 +++++++++++++++++++++ server/config.js | 13 +++++++++++++ server/routes.js | 41 ++++++++++++++++++++++++++++++++++++++++- 5 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 livekit.yaml diff --git a/.env.example b/.env.example index 1e93e00..7d0b1eb 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,11 @@ TURN_CREDENTIAL= # Optional: BizGaze webhook endpoint for session events. # BIZGAZE_WEBHOOK_URL= + +# Optional: LiveKit SFU for meetings (scales past the ~5-peer P2P mesh). Set ALL THREE to enable; +# leave unset to keep the built-in mesh. The app mints join tokens with the secret (server-side +# only); the same key/secret feed the livekit container via LIVEKIT_KEYS in docker-compose. +# Generate a key/secret pair: two random strings, e.g. `openssl rand -hex 16` for each. +# LIVEKIT_URL=wss://livekit.bizgaze.com +# LIVEKIT_API_KEY= +# LIVEKIT_API_SECRET= diff --git a/docker-compose.yml b/docker-compose.yml index 98e1d53..633a385 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,26 @@ services: networks: - npm + # LiveKit SFU — meeting media server. Optional: only started/used when the app's .env has + # LIVEKIT_URL/API_KEY/API_SECRET set (otherwise meetings use the built-in P2P mesh). NPM proxies + # wss://livekit.bizgaze.com -> livekit:7880 (signaling); media flows over the published UDP/TCP + # ports below, NOT through NPM. Single-node (no Redis) — consistent with the app's single-instance rule. + livekit: + image: livekit/livekit-server:v1.7 + container_name: bizgaze-livekit + restart: unless-stopped + command: --config /etc/livekit.yaml + environment: + # key: secret, sourced from the same .env as the app so both sign/verify with the same secret. + - "LIVEKIT_KEYS=${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}" + volumes: + - ./livekit.yaml:/etc/livekit.yaml:ro + ports: + - "7881:7881" # WebRTC over TCP (fallback) + - "50000-50100:50000-50100/udp" # WebRTC media (UDP) — must match livekit.yaml port range + networks: + - npm + networks: npm: external: true diff --git a/livekit.yaml b/livekit.yaml new file mode 100644 index 0000000..ca9c9e2 --- /dev/null +++ b/livekit.yaml @@ -0,0 +1,21 @@ +# LiveKit SFU config (non-secret — the API key/secret are injected via the LIVEKIT_KEYS env var +# in docker-compose, sourced from .env, so nothing secret lives in git). +# +# Media plane: LiveKit needs UDP reachable from clients (NPM only proxies the HTTP/WS signaling on +# 7880). The UDP range + TCP fallback below are published as HOST ports in docker-compose. On the +# VPS, if the server sits behind NAT and can't auto-detect its public IP, set rtc.node_ip to it. +port: 7880 # signaling (HTTP/WS) — NPM proxies wss://livekit.bizgaze.com -> here +rtc: + tcp_port: 7881 # WebRTC-over-TCP fallback (restrictive networks) + port_range_start: 50000 # WebRTC media (UDP) — keep in sync with the published range in compose + port_range_end: 50100 + use_external_ip: true # discover the public IP for ICE candidates (VPS). Or set node_ip below. + # node_ip: 118.95.33.89 # uncomment + set if use_external_ip can't detect the public IP + +# Embedded TURN over TLS on 443 helps clients on locked-down networks. Left off by default because +# NPM already owns 443; enable via a dedicated hostname + NPM stream if you need it (see DEPLOY.md). +turn: + enabled: false + +logging: + level: info diff --git a/server/config.js b/server/config.js index 25a25d2..3637010 100644 --- a/server/config.js +++ b/server/config.js @@ -14,9 +14,22 @@ try { fs.mkdirSync(TRANS_DIR, { recursive: true }); } catch (e) {} try { fs.mkdirSync(UPLOADS_DIR, { recursive: true }); } catch (e) {} try { fs.mkdirSync(DOWNLOADS_DIR, { recursive: true }); } catch (e) {} +// LiveKit SFU (scales meetings past the ~5-peer mesh ceiling). Entirely optional and config-gated: +// when LIVEKIT_URL/API_KEY/API_SECRET are all set the client uses LiveKit for meeting media; when +// they're unset the app falls back to the built-in P2P mesh, unchanged. The API secret is used +// ONLY server-side to mint per-user join tokens — it never reaches the browser. +const LIVEKIT_URL = process.env.LIVEKIT_URL || ''; // wss://livekit.bizgaze.com +const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || ''; +const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || ''; +const LIVEKIT_ENABLED = !!(LIVEKIT_URL && LIVEKIT_API_KEY && LIVEKIT_API_SECRET); + module.exports = { PORT: process.env.PORT || 8090, HTTPS_PORT: process.env.HTTPS_PORT || 8443, + LIVEKIT_URL, + LIVEKIT_API_KEY, + LIVEKIT_API_SECRET, + LIVEKIT_ENABLED, PUBLIC_DIR, REC_DIR, TRANS_DIR, diff --git a/server/routes.js b/server/routes.js index ee3d479..937d33a 100644 --- a/server/routes.js +++ b/server/routes.js @@ -83,9 +83,27 @@ const API_KEY_SCOPES = ['report:read', 'audit:read']; const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence'); const CALLS = require('./calls'); require('./reminders'); // start the 10-minute meeting-reminder loop -const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL } = require('./config'); +const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED } = require('./config'); +const crypto = require('crypto'); const MAX_FILE_BYTES = 25 * 1024 * 1024; // 25 MB per chat attachment +// Mint a LiveKit access token (HS256 JWT signed with the API secret) — same hand-rolled JWT +// approach as push.js's FCM/APNs tokens, so no extra dependency. Grants the holder join+publish+ +// subscribe on exactly one room, as one identity. Secret stays server-side. +const _b64u = (buf) => Buffer.from(buf).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); +function livekitToken(identity, name, room, metadata) { + const nowSec = Math.floor(Date.now() / 1000); + const header = _b64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const payload = _b64u(JSON.stringify({ + iss: LIVEKIT_API_KEY, sub: identity, name: name || identity, + nbf: nowSec, exp: nowSec + 6 * 3600, // 6h — long enough for any meeting + metadata: metadata || '', + video: { room, roomJoin: true, canPublish: true, canSubscribe: true, canPublishData: true }, + })); + const sig = _b64u(crypto.createHmac('sha256', LIVEKIT_API_SECRET).update(header + '.' + payload).digest()); + return header + '.' + payload + '.' + sig; +} + // Issue a refresh token (native clients), store only its hash, return the plaintext once. function issueRefreshToken(userId) { const rtok = A.token(32); @@ -865,6 +883,27 @@ route('POST', '/api/calls/invite', async (req, res) => { json(res, 200, { ok: true, invited: ids.length }); }); +// Does this deployment use the LiveKit SFU for meeting media? The client asks on load; if sfu is +// false it uses the built-in P2P mesh. url is the browser-facing signaling endpoint (wss://…). +route('GET', '/api/meetings/config', (req, res) => { + json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '' }); +}); + +// Mint a LiveKit join token for the signed-in user + a specific room (the 6-digit meeting code). +// The room-membership/host authorization already happens over the meeting WebSocket; this only +// hands the client a media-plane credential scoped to that room and its own identity. +route('POST', '/api/meetings/token', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + if (!LIVEKIT_ENABLED) return json(res, 501, { error: 'sfu not configured' }); + const { room } = await readBody(req); + const rm = String(room || '').trim(); + if (!/^[A-Za-z0-9._-]{4,64}$/.test(rm)) return json(res, 400, { error: 'invalid room' }); + const metadata = JSON.stringify({ avatarUrl: u.avatar_url || '' }); + const token = livekitToken(u.id, u.name || u.email, rm, metadata); + json(res, 200, { token, url: LIVEKIT_URL, identity: u.id, name: u.name || u.email }); +}); + // Decline an incoming 1:1 call: drops the caller, posts a "Call declined" line, clears the call. route('POST', '/api/calls/decline', async (req, res) => { const u = currentUser(req);