aboutsummaryrefslogtreecommitdiffstats
path: root/live/server.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-18 08:31:40 -0700
committerAdam Soltys <[email protected]>2026-10-18 08:31:40 -0700
commit265fc554840455c87b7e46a2f42bcb51e22318c1 (patch)
tree7054001d62663cef1acdeaec797f5379daf78e4a /live/server.js
parent64826cb02e77dace0c4121d7c2a9d2d1c5163092 (diff)
downloadcoinosv3-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
Diffstat (limited to 'live/server.js')
-rw-r--r--live/server.js43
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);