Files
securebit-chat/src/network/turnCredentials.js
T

131 lines
5.1 KiB
JavaScript
Raw Normal View History

// Keeps the credential for SecureBit's own TURN relay fresh.
//
// The relay's credential is no longer something the client carries: it is minted
// by the site (POST /api/turn-credentials, see deploy/turn-credentials.js) and
// expires after a day. This module fetches one at startup and again before it
// runs out, and writes it into the ICE list the connection managers already hold.
//
// The update is made IN PLACE on the existing entry objects. Every manager keeps a
// reference to window.SECUREBIT_ICE_SERVERS rather than a copy, so the next peer
// connection — and the next in-band ICE restart, which re-reads the list — picks
// up the new credential without anything else having to know it changed.
//
// If the endpoint cannot be reached, nothing is changed and the entry keeps the
// credential it shipped with. Only our relay's entries are touched; a TURN server
// the user configured themselves is never modified.
const ENDPOINT = '/api/turn-credentials';
const OWN_RELAY_HOSTS = ['turn.securebit.chat', '144.172.96.126'];
const MAX_FIELD = 512;
// How long to wait before trying again after a failed fetch.
const RETRY_DELAYS_MS = [60_000, 5 * 60_000, 15 * 60_000];
function relayHost(url) {
const m = /^turns?:([^:?\s]+)/i.exec(String(url || '').trim());
return m ? m[1].toLowerCase() : null;
}
/** True when an ICE entry points at SecureBit's own relay. */
export function isOwnRelayEntry(entry) {
if (!entry || typeof entry !== 'object') return false;
const urls = Array.isArray(entry.urls) ? entry.urls : [entry.urls];
return urls.some((u) => OWN_RELAY_HOSTS.includes(relayHost(u)));
}
function isCleanField(value) {
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_FIELD) return false;
for (let i = 0; i < value.length; i++) {
const c = value.charCodeAt(i);
if (c < 0x20 || c === 0x7f) return false;
}
return true;
}
/**
* Write a fresh credential into every own-relay entry of `list`, in place.
* @returns {boolean} whether anything was updated
*/
export function applyTurnCredentials(list, cred) {
if (!Array.isArray(list) || !cred || !isCleanField(cred.username) || !isCleanField(cred.credential)) {
return false;
}
let updated = false;
for (const entry of list) {
if (!isOwnRelayEntry(entry)) continue;
entry.username = cred.username;
entry.credential = cred.credential;
updated = true;
}
return updated;
}
/** Pull the credential out of an endpoint response, or null if it is not usable. */
export function parseCredentialResponse(body, nowSeconds) {
const server = body && Array.isArray(body.iceServers) ? body.iceServers[0] : null;
if (!server || !isCleanField(server.username) || !isCleanField(server.credential)) return null;
// coturn REST-API usernames are "<expiry>:<label>"; refuse one already expired.
const expiry = Number(String(server.username).split(':')[0]);
if (!Number.isFinite(expiry) || expiry <= nowSeconds) return null;
const ttl = Number(body.ttl);
return {
username: server.username,
credential: server.credential,
expiry,
ttl: Number.isFinite(ttl) && ttl > 0 ? ttl : expiry - nowSeconds,
};
}
let started = false;
/**
* Start keeping the relay credential fresh. Safe to call more than once.
* @param {object} [opts]
* @param {() => Array} [opts.getList] the ICE list to update
*/
export function startTurnCredentialRefresh(opts = {}) {
if (started || typeof window === 'undefined' || typeof fetch !== 'function') return;
started = true;
const getList = opts.getList || (() => window.SECUREBIT_ICE_SERVERS);
let expiry = 0;
let ttl = 0;
let timer = null;
let failures = 0;
const schedule = (ms) => {
if (timer) clearTimeout(timer);
timer = setTimeout(refresh, ms);
};
async function refresh() {
timer = null;
try {
const res = await fetch(ENDPOINT, { method: 'POST', cache: 'no-store', credentials: 'omit' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const cred = parseCredentialResponse(await res.json(), Date.now() / 1000);
if (!cred) throw new Error('unusable response');
applyTurnCredentials(getList(), cred);
expiry = cred.expiry;
ttl = cred.ttl;
failures = 0;
// Renew at half-life, so a call started just before renewal still has
// hours of validity left for the relay to refresh its allocation.
schedule(Math.max(60, ttl / 2) * 1000);
} catch (_) {
const delay = RETRY_DELAYS_MS[Math.min(failures, RETRY_DELAYS_MS.length - 1)];
failures++;
schedule(delay);
}
}
// Timers are throttled in background tabs and stop while a laptop sleeps; on
// coming back, renew straight away if the credential is past its half-life.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') return;
const now = Date.now() / 1000;
if (!expiry || expiry - now < ttl / 2) refresh();
});
refresh();
}