diff options
Diffstat (limited to 'live/server.js')
| -rw-r--r-- | live/server.js | 43 |
1 files changed, 39 insertions, 4 deletions
diff --git a/live/server.js b/live/server.js index 2aaa355..d764d94 100644 --- a/live/server.js +++ b/live/server.js @@ -20,6 +20,8 @@ // GET /live/<id>/<run>-<n>.ts a segment // GET /live/<id>/stats {state, viewers, started, seconds} // GET /live/ping (WebSocket) says pong: sockets get through +// GET /live/turn ICE servers for a call (Cloudflare TURN, a few hours' +// credentials), Authorization: Nostr <NIP-98 for GET /live/turn> // // Who may send: a coinos name's owner (its nostr key or the wallet key that // manages it), checked with the names registrar. One stream per person, @@ -38,6 +40,31 @@ const KEEP_DAYS = +(process.env.LIVE_KEEP_DAYS || 7); // recordings const CAP_BYTES = +(process.env.LIVE_CAP_GB || 30) * 1e9; const RESUME_MS = 60_000; // a dropped sender may come back this long const IDLE_MS = 20_000; // a sender with nothing for this long is gone +// Calls (the app's 1:1 voice and video, NIP-AC): TURN for the connections +// that can't go direct, from Cloudflare's TURN service; the key is in +// $LIVE_CONFIG ({turnKeyId, turnKeyToken}). +const CONFIG = (() => { try { return JSON.parse(readFileSync(process.env.LIVE_CONFIG || '/etc/coinosv3/config.json', 'utf8')); } catch { return {}; } })(); +const TURN_TTL = 4 * 3600; +const STUN_ONLY = [{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.l.google.com:19302'] }]; +const turnAsks = new Map(); // pubkey or ip -> [times] +function turnOk(key, max) { + const now = Date.now(), list = (turnAsks.get(key) || []).filter((t) => now - t < 3600_000); + if (list.length >= max) return false; + list.push(now); turnAsks.set(key, list); + if (turnAsks.size > 20000) turnAsks.clear(); + return true; +} +async function turnServers() { + if (!CONFIG.turnKeyId || !CONFIG.turnKeyToken) return STUN_ONLY; + const r = await fetch(`https://rtc.live.cloudflare.com/v1/turn/keys/${CONFIG.turnKeyId}/credentials/generate-ice-servers`, { + method: 'POST', signal: AbortSignal.timeout(8000), + headers: { authorization: 'Bearer ' + CONFIG.turnKeyToken, 'content-type': 'application/json', 'user-agent': 'coinos-live/1' }, + body: JSON.stringify({ ttl: TURN_TTL }), + }); + if (!r.ok) throw new Error('turn ' + r.status); + const j = await r.json(); + return Array.isArray(j.iceServers) ? j.iceServers : [j.iceServers].filter(Boolean); +} const WINDOW = 8; // segments in the live playlist const SEG = 2; // seconds a segment @@ -195,16 +222,17 @@ async function ownsName(name, pubkey) { return !!j.taken && (j.pubkey === pubkey || j.manager === pubkey); } catch { return false; } } -function checkAuth(url) { +function checkAuth(url, req, path = '/live/ingest') { let evt; - try { evt = JSON.parse(Buffer.from(url.searchParams.get('auth') || '', 'base64').toString('utf8')); } catch { return { error: 'bad auth' }; } + const header = /^Nostr\s+(.+)$/i.exec((req && req.headers.get('authorization')) || ''); + try { evt = JSON.parse(Buffer.from((header && header[1]) || url.searchParams.get('auth') || '', 'base64').toString('utf8')); } catch { return { error: 'bad auth' }; } if (!evt || evt.kind !== 27235) return { error: 'auth must be kind 27235' }; if (Math.abs(Date.now() / 1000 - (evt.created_at || 0)) > 60) return { error: 'auth expired' }; if (!verifyEvent(evt)) return { error: 'bad signature' }; const tag = (k) => (evt.tags.find((x) => x[0] === k) || [])[1] || ''; if (tag('method').toUpperCase() !== 'GET') return { error: 'method mismatch' }; let u; try { u = new URL(tag('u')); } catch { return { error: 'bad url tag' }; } - if (u.pathname !== '/live/ingest') return { error: 'url mismatch' }; + if (u.pathname !== path) return { error: 'url mismatch' }; return { pubkey: evt.pubkey }; } @@ -222,10 +250,17 @@ export async function handle(req, server) { const url = new URL(req.url); const path = url.pathname.replace(/^\/live/, ''); if (path === '/health') return json({ ok: true, live: live().length }); + if (path === '/turn' && req.method === 'GET') { + const a = checkAuth(url, req, '/live/turn'); + if (a.error) return json({ error: a.error }, 401); + if (!turnOk('pk:' + a.pubkey, 30) || !turnOk('ip:' + ipOf(req, server), 60)) return json({ error: 'rate limited' }, 429); + try { return json({ iceServers: await turnServers(), ttl: TURN_TTL }); } + catch (e) { log('turn', e.message); return json({ iceServers: STUN_ONLY, ttl: 600 }); } + } // a WebSocket that says pong: is the way in (Cloudflare, the proxies) open to sockets? if (path === '/ping') return server.upgrade(req, { data: { ping: true } }) ? undefined : json({ error: 'websocket expected' }, 426); if (path === '/ingest') { - const a = checkAuth(url); + const a = checkAuth(url, req); if (a.error) return json({ error: a.error }, 401); const name = (url.searchParams.get('name') || '').toLowerCase(); if (!(await ownsName(name, a.pubkey))) return json({ error: 'only a coinos name\'s owner can go live' }, 403); |
