feat: stream private office screens
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { parseScryptHash, type ScryptHash } from "./auth/password.ts";
|
||||
import { loadRegions, type RegionSet } from "./regions.ts";
|
||||
import { isSafeIceUrl } from "../../src/media/iceValidation.ts";
|
||||
import type {
|
||||
AuthMode,
|
||||
FlightsSourceId,
|
||||
@@ -136,6 +137,16 @@ export interface AuthConfig {
|
||||
admins: AdminGrant;
|
||||
}
|
||||
|
||||
export interface IceConfig {
|
||||
configured: boolean;
|
||||
urls: string[];
|
||||
/** Server-only coturn REST shared secret. Never serialize this config. */
|
||||
sharedSecret: string;
|
||||
credentialTtlSeconds: number;
|
||||
rateAttempts: number;
|
||||
rateWindowSeconds: number;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -168,6 +179,7 @@ export interface Config {
|
||||
*/
|
||||
presence: { dir: string };
|
||||
auth: AuthConfig;
|
||||
ice: IceConfig;
|
||||
/** One sentence per demotion. Empty on a fully-configured box. */
|
||||
degraded: string[];
|
||||
}
|
||||
@@ -181,6 +193,7 @@ export function loadConfig(env: Env = process.env): Config {
|
||||
const flights = loadFlights(env, degraded);
|
||||
const satellites = loadSatellites(env, degraded);
|
||||
const auth = loadAuth(env, degraded);
|
||||
const ice = loadIce(env, degraded);
|
||||
// After auth, because a marker feed with nobody able to sign in is worth a
|
||||
// sentence and the sentence is only true once `mode` has finished demoting.
|
||||
const markers = loadMarkers(env, auth.mode, degraded);
|
||||
@@ -212,12 +225,59 @@ export function loadConfig(env: Env = process.env): Config {
|
||||
offices: { dir: str(env, "TERA_OFFICES_DIR", "") },
|
||||
presence: { dir: str(env, "TERA_PRESENCE_DIR", "") },
|
||||
auth,
|
||||
ice,
|
||||
degraded,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Sections -------------------------------------------------------------
|
||||
|
||||
function loadIce(env: Env, degraded: string[]): IceConfig {
|
||||
const rawUrls = env.TERA_ICE_URLS;
|
||||
const rawSecret = env.TERA_TURN_SHARED_SECRET;
|
||||
const absent = (rawUrls === undefined || rawUrls.trim() === "") &&
|
||||
(rawSecret === undefined || rawSecret === "");
|
||||
const disabled: IceConfig = {
|
||||
configured: false,
|
||||
urls: [],
|
||||
sharedSecret: "",
|
||||
credentialTtlSeconds: 300,
|
||||
rateAttempts: 30,
|
||||
rateWindowSeconds: 60,
|
||||
};
|
||||
if (absent) return disabled;
|
||||
|
||||
const urls = (rawUrls ?? "").split(",").map((value) => value.trim()).filter(Boolean);
|
||||
const secret = rawSecret ?? "";
|
||||
const ttl = strictInteger(env.TERA_TURN_CREDENTIAL_TTL, 300, 60, 3_600);
|
||||
const attempts = strictInteger(env.TERA_ICE_RATE_ATTEMPTS, 30, 1, 300);
|
||||
const windowSeconds = strictInteger(env.TERA_ICE_RATE_WINDOW, 60, 1, 3_600);
|
||||
const urlsValid = urls.length > 0 && urls.length <= 8 && new Set(urls).size === urls.length &&
|
||||
urls.every(isSafeIceUrl) && urls.some((url) => url.startsWith("turn:") || url.startsWith("turns:"));
|
||||
const secretValid = secret.length >= 32 && secret.length <= 4_096 && secret === secret.trim() &&
|
||||
!/[\u0000-\u001f\u007f]/.test(secret);
|
||||
|
||||
if (!urlsValid || !secretValid || ttl === null || attempts === null || windowSeconds === null) {
|
||||
degraded.push("ICE credential service is disabled because its TURN configuration is incomplete or invalid.");
|
||||
return disabled;
|
||||
}
|
||||
return {
|
||||
configured: true,
|
||||
urls,
|
||||
sharedSecret: secret,
|
||||
credentialTtlSeconds: ttl,
|
||||
rateAttempts: attempts,
|
||||
rateWindowSeconds: windowSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
function strictInteger(raw: string | undefined, fallback: number, minimum: number, maximum: number): number | null {
|
||||
if (raw === undefined || raw === "") return fallback;
|
||||
if (!/^[0-9]+$/.test(raw)) return null;
|
||||
const parsed = Number(raw);
|
||||
return Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum ? parsed : null;
|
||||
}
|
||||
|
||||
const WEATHER_SOURCES: WeatherSourceId[] = ["none", "nws", "metno", "openmeteo"];
|
||||
|
||||
function loadWeather(env: Env, degraded: string[]): WeatherConfig {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { createHmac, randomBytes } from "node:crypto";
|
||||
import {
|
||||
ICE_CONFIG_PROTOCOL_VERSION,
|
||||
type IceConfigGrant,
|
||||
type IceConfigRequest,
|
||||
type IceConfigUnavailable,
|
||||
type StunIceServer,
|
||||
type TurnIceServer,
|
||||
} from "../../../src/media/iceTypes.ts";
|
||||
import type { IceConfig } from "../config.ts";
|
||||
|
||||
export type IceCredentialResult =
|
||||
| { ok: true; value: IceConfigGrant }
|
||||
| { ok: false; code: "unavailable" | "rate-limited"; value: IceConfigUnavailable };
|
||||
|
||||
export interface IceCredentialProvider {
|
||||
issue(request: IceConfigRequest, rateKey: string): IceCredentialResult;
|
||||
}
|
||||
|
||||
export interface IceCredentialProviderOptions {
|
||||
now?: () => number;
|
||||
nonce?: () => string;
|
||||
}
|
||||
|
||||
interface RateEntry { startedAtMs: number; count: number }
|
||||
const MAX_RATE_KEYS = 4_096;
|
||||
|
||||
export function createIceCredentialProvider(
|
||||
config: IceConfig,
|
||||
options: IceCredentialProviderOptions = {},
|
||||
): IceCredentialProvider {
|
||||
const now = options.now ?? Date.now;
|
||||
const nonce = options.nonce ?? (() => randomBytes(18).toString("base64url"));
|
||||
const rates = new Map<string, RateEntry>();
|
||||
const windowMs = config.rateWindowSeconds * 1_000;
|
||||
|
||||
return {
|
||||
issue(request, rateKey) {
|
||||
const at = now();
|
||||
if (!config.configured) return unavailable(request.requestId, windowMs, "unavailable");
|
||||
let entry = rates.get(rateKey);
|
||||
if (!entry && rates.size >= MAX_RATE_KEYS) {
|
||||
for (const [key, value] of rates) {
|
||||
if (at - value.startedAtMs >= windowMs) rates.delete(key);
|
||||
}
|
||||
if (rates.size >= MAX_RATE_KEYS) {
|
||||
return unavailable(request.requestId, windowMs, "rate-limited");
|
||||
}
|
||||
entry = rates.get(rateKey);
|
||||
}
|
||||
if (!entry || at - entry.startedAtMs >= windowMs) {
|
||||
rates.set(rateKey, { startedAtMs: at, count: 1 });
|
||||
} else {
|
||||
entry.count += 1;
|
||||
if (entry.count > config.rateAttempts) {
|
||||
return unavailable(request.requestId, Math.max(1_000, windowMs - (at - entry.startedAtMs)), "rate-limited");
|
||||
}
|
||||
}
|
||||
|
||||
const expiresAtSeconds = Math.floor(at / 1_000) + config.credentialTtlSeconds;
|
||||
// Coturn REST convention: expiration timestamp, colon, opaque username.
|
||||
// No auth subject, email, profile id, IP address, or stable browser id.
|
||||
const username = `${expiresAtSeconds}:${nonce()}`;
|
||||
const credential = createHmac("sha1", config.sharedSecret).update(username).digest("base64");
|
||||
const stunUrls = config.urls.filter((url) => url.startsWith("stun:") || url.startsWith("stuns:"));
|
||||
const turnUrls = config.urls.filter((url) => url.startsWith("turn:") || url.startsWith("turns:"));
|
||||
const iceServers: Array<StunIceServer | TurnIceServer> = [];
|
||||
if (stunUrls.length > 0) iceServers.push({ urls: stunUrls });
|
||||
iceServers.push({ urls: turnUrls, username, credential, credentialType: "password" });
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
type: "ice-config-grant",
|
||||
protocolVersion: ICE_CONFIG_PROTOCOL_VERSION,
|
||||
requestId: request.requestId,
|
||||
issuedAtMs: at,
|
||||
expiresAtMs: expiresAtSeconds * 1_000,
|
||||
iceServers,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function unavailable(
|
||||
requestId: string,
|
||||
retryAfterMs: number,
|
||||
code: "unavailable" | "rate-limited",
|
||||
): IceCredentialResult {
|
||||
return {
|
||||
ok: false,
|
||||
code,
|
||||
value: {
|
||||
type: "ice-config-unavailable",
|
||||
protocolVersion: ICE_CONFIG_PROTOCOL_VERSION,
|
||||
requestId,
|
||||
retryAfterMs: Math.min(3_600_000, Math.max(1_000, Math.ceil(retryAfterMs))),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,3 +6,9 @@ export {
|
||||
type MediaSignalServiceOptions,
|
||||
} from "./service.ts";
|
||||
export { bundledMediaOffice, officeHasMediaBinding } from "./bindings.ts";
|
||||
export {
|
||||
createIceCredentialProvider,
|
||||
type IceCredentialProvider,
|
||||
type IceCredentialProviderOptions,
|
||||
type IceCredentialResult,
|
||||
} from "./ice.ts";
|
||||
|
||||
@@ -63,6 +63,7 @@ interface Participant {
|
||||
rateStartedAt: number;
|
||||
rateCount: number;
|
||||
queue: ScreenSharePeerMessage[];
|
||||
queueOverflowed: boolean;
|
||||
listeners: Set<(message: ScreenSharePeerMessage) => void>;
|
||||
}
|
||||
|
||||
@@ -121,6 +122,7 @@ export function createMediaSignalService(options: MediaSignalServiceOptions = {}
|
||||
// snapshot, so dropping the oldest relay is safer than letting a sender
|
||||
// evict another participant by filling its queue.
|
||||
participant.queue.shift();
|
||||
participant.queueOverflowed = true;
|
||||
}
|
||||
participant.queue.push(message);
|
||||
}
|
||||
@@ -207,10 +209,9 @@ export function createMediaSignalService(options: MediaSignalServiceOptions = {}
|
||||
sequence: number,
|
||||
): MediaSignalResult<null> {
|
||||
if (!sameBinding(session.binding, binding)) return { ok: false, code: "unauthorized", message: "Media binding does not match the grant." };
|
||||
if (!Number.isSafeInteger(sequence) || sequence <= participant.lastClientSequence) {
|
||||
if (!clientSequenceHasSuccessor(sequence) || sequence <= participant.lastClientSequence) {
|
||||
return { ok: false, code: "conflict", message: "Media client sequence is not monotonic." };
|
||||
}
|
||||
participant.lastClientSequence = sequence;
|
||||
return { ok: true, value: null };
|
||||
}
|
||||
|
||||
@@ -220,10 +221,14 @@ export function createMediaSignalService(options: MediaSignalServiceOptions = {}
|
||||
return { token, participant: {
|
||||
id: opaque(18), subject, role, tokenHash: hash(token), issuedAt, expiresAt: issuedAt + ttl,
|
||||
lastClientSequence: clientSequence, rateStartedAt: issuedAt, rateCount: 0, queue: [], listeners: new Set(),
|
||||
queueOverflowed: false,
|
||||
} };
|
||||
}
|
||||
|
||||
function join(request: ScreenShareCreateRequest | ScreenShareJoinRequest, subject: string): MediaSignalResult<ScreenShareCreateGrant | ScreenShareJoinGrant> {
|
||||
if (!clientSequenceHasSuccessor(request.sequence)) {
|
||||
return { ok: false, code: "invalid", message: "Media client sequence cannot advance." };
|
||||
}
|
||||
cleanup();
|
||||
if (request.type === "screen-share-create-request") {
|
||||
if (sessions.size >= maximumSessions) return { ok: false, code: "capacity", message: "Media signaling is at capacity." };
|
||||
@@ -263,22 +268,35 @@ export function createMediaSignalService(options: MediaSignalServiceOptions = {}
|
||||
const found = authenticate(request.credential, subject);
|
||||
if (!found.ok) return found;
|
||||
const { session, participant } = found.value;
|
||||
if (request.lastReceivedSequence > session.sequence) {
|
||||
return { ok: false, code: "conflict", message: "Media server sequence is ahead of this session." };
|
||||
}
|
||||
const valid = validateRequest(session, participant, request.binding, request.sequence);
|
||||
if (!valid.ok) return valid;
|
||||
participant.lastClientSequence = request.sequence;
|
||||
const token = opaque(32);
|
||||
participant.tokenHash.fill(0);
|
||||
participant.tokenHash = hash(token);
|
||||
participant.issuedAt = now();
|
||||
participant.expiresAt = now() + ttl;
|
||||
// A resume grant carries a complete participant snapshot. Dropping queued
|
||||
// relays avoids replaying a message with a sequence older than that grant;
|
||||
// clients renegotiate when `continuous` is false.
|
||||
participant.queue.length = 0;
|
||||
const continuous = !participant.queueOverflowed;
|
||||
const queued = participant.queue.filter((message) => message.sequence > request.lastReceivedSequence);
|
||||
const grantSequence = nextSequence(session);
|
||||
const resumedAt = now();
|
||||
// The resume grant is written before subscribe drains this queue. Rebase
|
||||
// retained messages onto fresh server sequences so a strict client cursor
|
||||
// can accept them after the grant instead of treating them as stale.
|
||||
participant.queue = queued.map((message) => ({
|
||||
...message,
|
||||
sequence: nextSequence(session),
|
||||
timestampMs: resumedAt,
|
||||
}));
|
||||
participant.queueOverflowed = false;
|
||||
return { ok: true, value: {
|
||||
type: "screen-share-resume-grant", protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
|
||||
requestId: request.requestId, sequence: nextSequence(session), timestampMs: now(), binding: session.binding,
|
||||
requestId: request.requestId, sequence: grantSequence, timestampMs: resumedAt, binding: session.binding,
|
||||
grant: grant(session, participant, token), nextClientSequence: request.sequence + 1,
|
||||
participants: peerList(session, participant.id), continuous: false,
|
||||
participants: peerList(session, participant.id), continuous,
|
||||
} };
|
||||
}
|
||||
|
||||
@@ -296,6 +314,7 @@ export function createMediaSignalService(options: MediaSignalServiceOptions = {}
|
||||
const permitted = participant.role === "presenter" ? request.signal.descriptionType === "offer" : request.signal.descriptionType === "answer";
|
||||
if (!permitted) return { ok: false, code: "unauthorized", message: "Media SDP role is not permitted." };
|
||||
}
|
||||
participant.lastClientSequence = request.sequence;
|
||||
const relay: ScreenShareSignalRelay = {
|
||||
type: "screen-share-signal-relay", protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
|
||||
sequence: nextSequence(session), timestampMs: now(), sessionId: session.id, binding: session.binding,
|
||||
@@ -313,12 +332,14 @@ export function createMediaSignalService(options: MediaSignalServiceOptions = {}
|
||||
if (!valid.ok) return valid;
|
||||
if (request.type === "screen-share-stop-request") {
|
||||
if (participant.role !== "presenter") return { ok: false, code: "unauthorized", message: "Only a presenter may stop a share." };
|
||||
participant.lastClientSequence = request.sequence;
|
||||
destroySession(session, stopNotice(session, request.reason));
|
||||
return { ok: true, value: null };
|
||||
}
|
||||
if (request.reason === "moderator-action" && !admin) return { ok: false, code: "unauthorized", message: "Moderator action requires an administrator." };
|
||||
if (request.scope === "session") {
|
||||
if (participant.role !== "presenter" && !admin) return { ok: false, code: "unauthorized", message: "Only a presenter or administrator may revoke a session." };
|
||||
participant.lastClientSequence = request.sequence;
|
||||
destroySession(session, {
|
||||
type: "screen-share-revoked", protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
|
||||
sequence: nextSequence(session), timestampMs: now(), sessionId: session.id, binding: session.binding,
|
||||
@@ -333,6 +354,7 @@ export function createMediaSignalService(options: MediaSignalServiceOptions = {}
|
||||
return { ok: false, code: "unauthorized", message: "Viewer may only leave its own session." };
|
||||
}
|
||||
if (target.role === "presenter") return { ok: false, code: "unauthorized", message: "Presenter must stop the session." };
|
||||
participant.lastClientSequence = request.sequence;
|
||||
removeParticipant(session, target, request.reason);
|
||||
return { ok: true, value: null };
|
||||
}
|
||||
@@ -400,6 +422,9 @@ function positive(value: number | undefined, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
function count(value: number | undefined, fallback: number): number { return Math.max(1, Math.floor(positive(value, fallback))); }
|
||||
function clientSequenceHasSuccessor(value: number): boolean {
|
||||
return Number.isSafeInteger(value) && value >= 0 && value < Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
function copyBinding(binding: ScreenShareBinding): ScreenShareBinding { return { ...binding }; }
|
||||
function sameBinding(a: ScreenShareBinding, b: ScreenShareBinding): boolean {
|
||||
return a.officeId === b.officeId && a.levelId === b.levelId && a.roomId === b.roomId && a.screenId === b.screenId;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import { parseScreenShareClientMessage } from "../../../src/media/signalingValidation.ts";
|
||||
import { parseIceConfigRequest } from "../../../src/media/iceValidation.ts";
|
||||
import type {
|
||||
ScreenShareBinding,
|
||||
ScreenShareCreateRequest,
|
||||
@@ -43,6 +44,23 @@ function parse(body: unknown) {
|
||||
}
|
||||
|
||||
export function registerMedia(app: FastifyInstance, services: Services): void {
|
||||
app.post<{ Body: unknown }>("/api/v1/media/ice", { bodyLimit: 1_024 }, async (req, reply) => {
|
||||
const viewer = await services.auth.resolve(req);
|
||||
if (!viewer.authenticated) {
|
||||
return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
|
||||
}
|
||||
const parsed = parseIceConfigRequest(req.body);
|
||||
if (!parsed.ok) return reply.code(400).send({ error: "invalid", message: "ICE configuration request is invalid." });
|
||||
const issued = services.ice.issue(parsed.value, req.ip);
|
||||
if (!issued.ok) {
|
||||
const statusCode = issued.code === "rate-limited" ? 429 : 503;
|
||||
return reply.code(statusCode)
|
||||
.header("retry-after", String(Math.ceil(issued.value.retryAfterMs / 1_000)))
|
||||
.send(issued.value);
|
||||
}
|
||||
return reply.code(200).header("pragma", "no-cache").send(issued.value);
|
||||
});
|
||||
|
||||
app.post<{ Body: unknown }>("/api/v1/media/join", { bodyLimit: BODY_LIMIT }, async (req, reply) => {
|
||||
const viewer = await services.auth.resolve(req);
|
||||
if (!viewer.authenticated || viewer.subject === null) {
|
||||
|
||||
@@ -10,7 +10,12 @@
|
||||
import { createAuth, type AuthService } from "./auth/index.ts";
|
||||
import { createFlightsService, type FlightsService } from "./flights/index.ts";
|
||||
import { createMarkerStore, type MarkerStore } from "./markers/store.ts";
|
||||
import { createMediaSignalService, type MediaSignalService } from "./media/index.ts";
|
||||
import {
|
||||
createIceCredentialProvider,
|
||||
createMediaSignalService,
|
||||
type IceCredentialProvider,
|
||||
type MediaSignalService,
|
||||
} from "./media/index.ts";
|
||||
import { createOfficeStore, type OfficeStore } from "./offices/store.ts";
|
||||
import { createPresenceStore, type PresenceStore } from "./presence/store.ts";
|
||||
import { createRealtimeService, type RealtimeService } from "./realtime/index.ts";
|
||||
@@ -25,6 +30,7 @@ export interface Services {
|
||||
satellites: SatellitesService;
|
||||
markers: MarkerStore;
|
||||
media: MediaSignalService;
|
||||
ice: IceCredentialProvider;
|
||||
offices: OfficeStore;
|
||||
presence: PresenceStore;
|
||||
realtime: RealtimeService;
|
||||
@@ -46,6 +52,7 @@ export function createServices(config: Config, log: ServiceLog): Services {
|
||||
satellites: createSatellitesService(config, log),
|
||||
markers: createMarkerStore(config, log),
|
||||
media: createMediaSignalService(),
|
||||
ice: createIceCredentialProvider(config.ice),
|
||||
offices: createOfficeStore(config.offices.dir),
|
||||
presence: createPresenceStore(config.presence.dir),
|
||||
realtime: createRealtimeService(),
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHmac } from "node:crypto";
|
||||
import { after, describe, it } from "node:test";
|
||||
import { buildApp } from "../app.ts";
|
||||
import { loadConfig, type IceConfig } from "../config.ts";
|
||||
import { createIceCredentialProvider } from "../media/index.ts";
|
||||
import { parseIceConfigResponse } from "../../../src/media/iceValidation.ts";
|
||||
import type { IceConfigGrant } from "../../../src/media/iceTypes.ts";
|
||||
|
||||
const SHARED_SECRET = "turn-shared-secret-with-at-least-thirty-two-bytes";
|
||||
const JWT_SECRET = "ice-route-jwt-secret-that-is-long-enough";
|
||||
|
||||
const configured = (overrides: Partial<IceConfig> = {}): IceConfig => ({
|
||||
configured: true,
|
||||
urls: ["stun:relay.example.test:3478", "turn:relay.example.test:3478", "turns:relay.example.test:5349"],
|
||||
sharedSecret: SHARED_SECRET,
|
||||
credentialTtlSeconds: 600,
|
||||
rateAttempts: 2,
|
||||
rateWindowSeconds: 60,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const request = { type: "ice-config-request", protocolVersion: 1, requestId: "ice-request-1" } as const;
|
||||
|
||||
function bearer(subject: string) {
|
||||
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
const signed = `${encode({ alg: "HS256", typ: "JWT" })}.${encode({ sub: subject, exp: Math.floor(Date.now() / 1000) + 600 })}`;
|
||||
return { authorization: `Bearer ${signed}.${createHmac("sha256", JWT_SECRET).update(signed).digest("base64url")}` };
|
||||
}
|
||||
|
||||
describe("coturn REST ICE credential provider", () => {
|
||||
it("uses an opaque nonce and the standard expiration:HMAC-SHA1 credential", () => {
|
||||
const provider = createIceCredentialProvider(configured(), {
|
||||
now: () => 1_700_000_000_500,
|
||||
nonce: () => "opaque_random_nonce_1234",
|
||||
});
|
||||
const result = provider.issue(request, "rate-key");
|
||||
assert.equal(result.ok, true);
|
||||
if (!result.ok) return;
|
||||
const turn = result.value.iceServers.find((server) => "username" in server);
|
||||
assert.ok(turn && "username" in turn);
|
||||
if (!turn || !("username" in turn)) return;
|
||||
assert.equal(turn.username, "1700000600:opaque_random_nonce_1234");
|
||||
assert.equal(turn.credential, createHmac("sha1", SHARED_SECRET).update(turn.username).digest("base64"));
|
||||
assert.equal(turn.username.includes("auth-subject"), false);
|
||||
assert.equal(JSON.stringify(result.value).includes(SHARED_SECRET), false);
|
||||
assert.equal(parseIceConfigResponse(result.value).ok, true);
|
||||
});
|
||||
|
||||
it("fails closed when unconfigured and rate bounds issuance", () => {
|
||||
const unavailable = createIceCredentialProvider(configured({ configured: false, sharedSecret: "", urls: [] }));
|
||||
const closed = unavailable.issue(request, "caller");
|
||||
assert.equal(closed.ok, false);
|
||||
if (!closed.ok) assert.equal(closed.code, "unavailable");
|
||||
const limited = createIceCredentialProvider(configured({ rateAttempts: 1 }), {
|
||||
now: () => 10_000, nonce: () => "opaque_random_nonce_1234",
|
||||
});
|
||||
assert.equal(limited.issue(request, "same-ip").ok, true);
|
||||
const second = limited.issue(request, "same-ip");
|
||||
assert.equal(second.ok, false);
|
||||
if (!second.ok) assert.equal(second.code, "rate-limited");
|
||||
assert.equal(limited.issue(request, "other-ip").ok, true);
|
||||
});
|
||||
|
||||
it("keeps the client-rate table bounded under address churn", () => {
|
||||
let time = 10_000;
|
||||
const provider = createIceCredentialProvider(configured({ rateAttempts: 2 }), {
|
||||
now: () => time, nonce: () => "opaque_random_nonce_1234",
|
||||
});
|
||||
for (let index = 0; index < 4_096; index += 1) {
|
||||
assert.equal(provider.issue(request, `client-${index}`).ok, true);
|
||||
}
|
||||
const full = provider.issue(request, "client-over-cap");
|
||||
assert.equal(full.ok, false);
|
||||
if (!full.ok) assert.equal(full.code, "rate-limited");
|
||||
time += 60_000;
|
||||
assert.equal(provider.issue(request, "client-after-window").ok, true);
|
||||
});
|
||||
|
||||
it("strictly disables partial or malformed environment configuration", () => {
|
||||
assert.equal(loadConfig({ TERA_ICE_URLS: "turn:relay.example.test:3478" }).ice.configured, false);
|
||||
assert.equal(loadConfig({ TERA_TURN_SHARED_SECRET: SHARED_SECRET }).ice.configured, false);
|
||||
assert.equal(loadConfig({
|
||||
TERA_ICE_URLS: "turn:user@relay.example.test:3478", TERA_TURN_SHARED_SECRET: SHARED_SECRET,
|
||||
}).ice.configured, false);
|
||||
assert.equal(loadConfig({
|
||||
TERA_ICE_URLS: "turn:relay.example.test:3478", TERA_TURN_SHARED_SECRET: SHARED_SECRET,
|
||||
TERA_TURN_CREDENTIAL_TTL: "60.5",
|
||||
}).ice.configured, false);
|
||||
const valid = loadConfig({
|
||||
TERA_ICE_URLS: "stun:relay.example.test:3478, turns:relay.example.test:5349",
|
||||
TERA_TURN_SHARED_SECRET: SHARED_SECRET,
|
||||
});
|
||||
assert.equal(valid.ice.configured, true);
|
||||
assert.equal(valid.degraded.some((line) => line.includes(SHARED_SECRET)), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authenticated ICE route", () => {
|
||||
it("returns private ephemeral credentials only to authenticated POST callers", async () => {
|
||||
const config = loadConfig({
|
||||
TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: JWT_SECRET,
|
||||
TERA_ICE_URLS: "turn:relay.example.test:3478", TERA_TURN_SHARED_SECRET: SHARED_SECRET,
|
||||
});
|
||||
config.logLevel = "silent";
|
||||
const app = buildApp(config); after(() => app.close());
|
||||
assert.equal((await app.inject({ method: "GET", url: "/api/v1/media/ice" })).statusCode, 404);
|
||||
assert.equal((await app.inject({ method: "POST", url: "/api/v1/media/ice", payload: request })).statusCode, 401);
|
||||
const malformed = await app.inject({
|
||||
method: "POST", url: "/api/v1/media/ice", headers: bearer("auth-subject"),
|
||||
payload: { ...request, profile: "do-not-send" },
|
||||
});
|
||||
assert.equal(malformed.statusCode, 400);
|
||||
const response = await app.inject({
|
||||
method: "POST", url: "/api/v1/media/ice", headers: bearer("auth-subject"), payload: request,
|
||||
});
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.headers["cache-control"], "private, no-store");
|
||||
assert.equal(response.headers.pragma, "no-cache");
|
||||
assert.equal(response.headers.location, undefined);
|
||||
const body = response.json<IceConfigGrant>();
|
||||
assert.equal(parseIceConfigResponse(body).ok, true);
|
||||
assert.equal(JSON.stringify(body).includes("auth-subject"), false);
|
||||
});
|
||||
|
||||
it("returns typed unavailable without emitting credentials when TURN is absent", async () => {
|
||||
const config = loadConfig({ TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: JWT_SECRET });
|
||||
config.logLevel = "silent";
|
||||
const app = buildApp(config); after(() => app.close());
|
||||
const response = await app.inject({
|
||||
method: "POST", url: "/api/v1/media/ice", headers: bearer("auth-subject"), payload: request,
|
||||
});
|
||||
assert.equal(response.statusCode, 503);
|
||||
assert.equal(response.json().type, "ice-config-unavailable");
|
||||
assert.equal(JSON.stringify(response.json()).includes("credential"), false);
|
||||
});
|
||||
});
|
||||
@@ -76,6 +76,87 @@ describe("office media signaling service", () => {
|
||||
assert.equal(service.sessionCount(), 0);
|
||||
});
|
||||
|
||||
it("delivers a queued offer after resume with a sequence newer than the grant", () => {
|
||||
const service = createMediaSignalService();
|
||||
const { presenter, viewer } = grants(service);
|
||||
const offered = service.signal({
|
||||
type: "screen-share-signal-request", protocolVersion: 1, sequence: 2, timestampMs: 1_001,
|
||||
binding: BINDING, credential: presenter.grant.credential,
|
||||
targetParticipantId: viewer.grant.credential.participantId,
|
||||
signal: { kind: "sdp", descriptionType: "offer", sdp: "v=0\r\na=ice-options:trickle" },
|
||||
}, "presenter-subject");
|
||||
assert.equal(offered.ok, true);
|
||||
|
||||
const resumed = service.resume({
|
||||
type: "screen-share-resume-request", protocolVersion: 1, requestId: "request-viewer-resume",
|
||||
sequence: 2, timestampMs: 1_002, binding: BINDING,
|
||||
credential: viewer.grant.credential, lastReceivedSequence: viewer.sequence,
|
||||
}, "viewer-subject");
|
||||
assert.equal(resumed.ok, true);
|
||||
if (!resumed.ok) return;
|
||||
|
||||
const delivered: Array<{ type: string; sequence: number }> = [];
|
||||
const subscribed = service.subscribe(
|
||||
resumed.value.grant.credential,
|
||||
"viewer-subject",
|
||||
(message) => delivered.push({ type: message.type, sequence: message.sequence }),
|
||||
);
|
||||
assert.equal(subscribed.ok, true);
|
||||
const relay = delivered.find((message) => message.type === "screen-share-signal-relay");
|
||||
assert.ok(relay, "queued offer relay is delivered after token rotation");
|
||||
assert.ok(relay.sequence > resumed.value.sequence, "relay follows the resume grant cursor");
|
||||
assert.ok(delivered.every((message) => message.sequence > resumed.value.sequence));
|
||||
});
|
||||
|
||||
it("rejects exhausted client sequences without orphaning sessions or grants", () => {
|
||||
const service = createMediaSignalService();
|
||||
const exhaustedCreate = service.join(create(Number.MAX_SAFE_INTEGER), "presenter-subject");
|
||||
assert.equal(exhaustedCreate.ok, false);
|
||||
assert.equal(service.sessionCount(), 0, "invalid create does not leave an unreachable session");
|
||||
|
||||
const presenter = service.join(create(), "presenter-subject");
|
||||
assert.equal(presenter.ok, true);
|
||||
const exhaustedJoin = service.join(join(Number.MAX_SAFE_INTEGER), "viewer-subject");
|
||||
assert.equal(exhaustedJoin.ok, false);
|
||||
const viewer = service.join(join(), "viewer-subject");
|
||||
assert.equal(viewer.ok, true, "invalid join does not leave an orphan participant");
|
||||
if (!viewer.ok || viewer.value.type !== "screen-share-join-grant") return;
|
||||
|
||||
const exhaustedResume = service.resume({
|
||||
type: "screen-share-resume-request", protocolVersion: 1, requestId: "request-exhausted-resume",
|
||||
sequence: Number.MAX_SAFE_INTEGER, timestampMs: 1_002, binding: BINDING,
|
||||
credential: viewer.value.grant.credential, lastReceivedSequence: viewer.value.sequence,
|
||||
}, "viewer-subject");
|
||||
assert.equal(exhaustedResume.ok, false);
|
||||
assert.equal(service.revalidate(viewer.value.grant.credential, "viewer-subject").ok, true,
|
||||
"rejected resume does not rotate away the usable grant");
|
||||
const correctedResume = service.resume({
|
||||
type: "screen-share-resume-request", protocolVersion: 1, requestId: "request-corrected-resume",
|
||||
sequence: 2, timestampMs: 1_003, binding: BINDING,
|
||||
credential: viewer.value.grant.credential, lastReceivedSequence: viewer.value.sequence,
|
||||
}, "viewer-subject");
|
||||
assert.equal(correctedResume.ok, true, "rejected resume does not consume its client sequence");
|
||||
});
|
||||
|
||||
it("commits a signaling sequence only after the request is authorized", () => {
|
||||
const service = createMediaSignalService();
|
||||
const { presenter, viewer } = grants(service);
|
||||
const received: string[] = [];
|
||||
service.subscribe(viewer.grant.credential, "viewer-subject", (message) => received.push(message.type));
|
||||
const offer: ScreenShareSignalRequest = {
|
||||
type: "screen-share-signal-request", protocolVersion: 1, sequence: 2, timestampMs: 1_002,
|
||||
binding: BINDING, credential: presenter.grant.credential,
|
||||
targetParticipantId: "missing-opaque-participant",
|
||||
signal: { kind: "sdp", descriptionType: "offer", sdp: "v=0" },
|
||||
};
|
||||
assert.equal(service.signal(offer, "presenter-subject").ok, false);
|
||||
assert.equal(service.signal({
|
||||
...offer,
|
||||
targetParticipantId: viewer.grant.credential.participantId,
|
||||
}, "presenter-subject").ok, true, "corrected request can reuse the rejected sequence");
|
||||
assert.deepEqual(received.filter((type) => type === "screen-share-signal-relay"), ["screen-share-signal-relay"]);
|
||||
});
|
||||
|
||||
it("bounds the mesh and sends viewer lifecycle snapshots", () => {
|
||||
const service = createMediaSignalService({ maximumParticipantsPerSession: 2 });
|
||||
const { presenter, viewer } = grants(service);
|
||||
@@ -163,4 +244,58 @@ describe("office media signaling routes", () => {
|
||||
assert.equal(streamed.url.includes("grantToken"), false);
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
it("streams a pre-subscription offer after a newer viewer resume grant", async () => {
|
||||
const config = loadConfig({ TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: SECRET }); config.logLevel = "silent";
|
||||
const app = buildApp(config); after(() => app.close());
|
||||
const origin = await app.listen({ host: "127.0.0.1", port: 0 });
|
||||
const post = async (path: string, subject: string, payload: unknown) => fetch(`${origin}${path}`, {
|
||||
method: "POST", headers: { ...bearer(subject), "content-type": "application/json" }, body: JSON.stringify(payload),
|
||||
});
|
||||
const presenterResponse = await post("/api/v1/media/join", "presenter", create());
|
||||
const viewerResponse = await post("/api/v1/media/join", "viewer", join());
|
||||
const presenter = await presenterResponse.json() as {
|
||||
grant: { credential: ScreenShareSignalRequest["credential"] };
|
||||
};
|
||||
const viewer = await viewerResponse.json() as {
|
||||
sequence: number;
|
||||
grant: { credential: ScreenShareSignalRequest["credential"] };
|
||||
};
|
||||
const offered = await post("/api/v1/media/signal", "presenter", {
|
||||
type: "screen-share-signal-request", protocolVersion: 1, sequence: 2, timestampMs: 1_002,
|
||||
binding: BINDING, credential: presenter.grant.credential,
|
||||
targetParticipantId: viewer.grant.credential.participantId,
|
||||
signal: { kind: "sdp", descriptionType: "offer", sdp: "v=0\r\na=ice-options:trickle" },
|
||||
});
|
||||
assert.equal(offered.status, 204);
|
||||
|
||||
const controller = new AbortController();
|
||||
const streamed = await fetch(`${origin}/api/v1/media/events`, {
|
||||
method: "POST", signal: controller.signal,
|
||||
headers: { ...bearer("viewer"), "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "screen-share-resume-request", protocolVersion: 1, requestId: "request-viewer-events",
|
||||
sequence: 2, timestampMs: 1_003, binding: BINDING,
|
||||
credential: viewer.grant.credential, lastReceivedSequence: viewer.sequence,
|
||||
}),
|
||||
});
|
||||
assert.equal(streamed.status, 200);
|
||||
const reader = streamed.body?.getReader();
|
||||
assert.ok(reader);
|
||||
let text = "";
|
||||
for (let reads = 0; reads < 3 && !text.includes("screen-share-signal-relay"); reads += 1) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
text += new TextDecoder().decode(chunk.value);
|
||||
}
|
||||
controller.abort();
|
||||
const messages = text.split("\n")
|
||||
.filter((line) => line.startsWith("data: "))
|
||||
.map((line) => JSON.parse(line.slice(6)) as { type: string; sequence: number });
|
||||
const grant = messages.find((message) => message.type === "screen-share-resume-grant");
|
||||
const relay = messages.find((message) => message.type === "screen-share-signal-relay");
|
||||
assert.ok(grant);
|
||||
assert.ok(relay, "queued offer reaches the fetch-stream after resume");
|
||||
assert.ok(relay.sequence > grant.sequence);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user