diff options
| author | Adam Soltys <[email protected]> | 2026-10-18 08:31:40 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-18 08:31:40 -0700 |
| commit | 265fc554840455c87b7e46a2f42bcb51e22318c1 (patch) | |
| tree | 7054001d62663cef1acdeaec797f5379daf78e4a | |
| parent | 64826cb02e77dace0c4121d7c2a9d2d1c5163092 (diff) | |
| download | coinosv3-server-265fc554840455c87b7e46a2f42bcb51e22318c1.tar.gz coinosv3-server-265fc554840455c87b7e46a2f42bcb51e22318c1.zip | |
live: /live/turn — ICE servers for the app's calls (Cloudflare TURN credentials good for 4 h, to a NIP-98-signed ask; 30 an hour per key, 60 per IP; STUN alone if TURN can't be had). The key comes from $STATE/live/config.json.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
| -rw-r--r-- | compose.yml | 4 | ||||
| -rw-r--r-- | live/server.js | 43 | ||||
| -rw-r--r-- | tools/live-ingest-test.js | 8 |
3 files changed, 50 insertions, 5 deletions
diff --git a/compose.yml b/compose.yml index d48ab2f..3cdf4a3 100644 --- a/compose.yml +++ b/compose.yml @@ -5,7 +5,7 @@ # $STATE/names/config.json $STATE/names/data/ (names.db) # $STATE/nwcpush/config.json $STATE/nwcpush/data/ # $STATE/blossom/data/ -# $STATE/live/data/ (streams and their recordings) +# $STATE/live/config.json $STATE/live/data/ (TURN key; streams and their recordings) services: names: image: oven/bun:1.3.14 @@ -70,9 +70,11 @@ services: LIVE_PORT: "8795" LIVE_BASE: https://v3.coinos.io/live NAMES_URL: http://names:8798 + LIVE_CONFIG: /etc/coinosv3/config.json volumes: - .:/app:ro - ${STATE}/live/data:/data + - ${STATE}/live/config.json:/etc/coinosv3/config.json:ro cpus: 4 mem_limit: 2g networks: [net] 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); diff --git a/tools/live-ingest-test.js b/tools/live-ingest-test.js index 2d57d74..7bb2489 100644 --- a/tools/live-ingest-test.js +++ b/tools/live-ingest-test.js @@ -51,6 +51,14 @@ try { check('...as is a name nobody owns', (await connect(`auth=${auth(SK)}&name=bob`)).error === true); check('...and a forged auth', (await connect(`auth=${auth(SK).slice(0, -8)}AAAAAAAA&name=alice`)).error === true); + console.log('\n[TURN for calls]'); + const turnAuth = (sk, path = '/live/turn') => 'Nostr ' + Buffer.from(JSON.stringify(finalizeEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', + tags: [['u', 'https://v3.coinos.io' + path], ['method', 'GET']] }, sk))).toString('base64'); + check('ICE servers need a signed ask', (await fetch(`${HTTP}/turn`)).status === 401); + check('...one signed for this address', (await fetch(`${HTTP}/turn`, { headers: { authorization: turnAuth(OTHER, '/live/ingest') } })).status === 401); + const ice = await (await fetch(`${HTTP}/turn`, { headers: { authorization: turnAuth(OTHER) } })).json(); + check('...anyone signed gets them (STUN alone with no TURN key configured)', Array.isArray(ice.iceServers) && /^stun:/.test(ice.iceServers[0].urls[0]), JSON.stringify(ice)); + console.log('\n[sending]'); const a = await connect(`auth=${auth(SK)}&name=alice`); check('the owner is let in and told where the stream is', !!a.hello?.id && /\/live\/[0-9a-f]{16}\/live\.m3u8$/.test(a.hello.live) && /index\.m3u8$/.test(a.hello.recording), JSON.stringify(a.hello)); |
