mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-29 05:52:31 +00:00
Use host preparation for TURN connection settings
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/** The source identifier persisted in a P2P profile for Cloudflare TURN. */
|
||||
export const CLOUDFLARE_ICE_SERVER_SOURCE_ID = "cloudflare" as const;
|
||||
/** The provider identifier persisted in a P2P profile for Cloudflare TURN. */
|
||||
export const CLOUDFLARE_TURN_TYPE = "CF" as const;
|
||||
|
||||
/** The lifetime requested from Cloudflare for each issued credential set. */
|
||||
export const CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS = 86_400 as const;
|
||||
@@ -7,14 +7,12 @@ export const CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS = 86_400 as const;
|
||||
/** The Cloudflare TURN credential-generation endpoint. */
|
||||
export const CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT = "https://rtc.live.cloudflare.com/v1/turn/keys" as const;
|
||||
|
||||
/** A validated Cloudflare TURN source configuration. */
|
||||
export interface CloudflareIceServerSourceConfiguration {
|
||||
/** A Cloudflare TURN configuration. */
|
||||
export interface CloudflareTurnConfiguration {
|
||||
readonly turnKeyId: string;
|
||||
readonly apiToken: string;
|
||||
}
|
||||
|
||||
const CLOUDFLARE_CONFIGURATION_KEYS = ["turnKeyId", "apiToken"] as const;
|
||||
|
||||
// TURN Key IDs are inserted into one fixed URL path. Keep the accepted set
|
||||
// deliberately narrower than URI escaping so a configuration cannot alter
|
||||
// the request path or add a query string.
|
||||
@@ -26,30 +24,11 @@ const TURN_KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,255}$/;
|
||||
const BEARER_TOKEN_PATTERN = /^[A-Za-z0-9._~+/-]+={0,2}$/;
|
||||
const MAX_BEARER_TOKEN_LENGTH = 4_096;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasOnlyCloudflareConfigurationKeys(value: Record<string, unknown>): boolean {
|
||||
const keys = Object.keys(value);
|
||||
return (
|
||||
keys.length === CLOUDFLARE_CONFIGURATION_KEYS.length &&
|
||||
CLOUDFLARE_CONFIGURATION_KEYS.every((key) => Object.prototype.hasOwnProperty.call(value, key))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a safe validation message for a Cloudflare source configuration.
|
||||
* Returns a safe validation message for a Cloudflare TURN configuration.
|
||||
* The result never includes the supplied Key ID or API token.
|
||||
*/
|
||||
export function validateCloudflareIceServerSourceConfiguration(value: unknown): string | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return "Cloudflare TURN configuration is invalid.";
|
||||
}
|
||||
if (!hasOnlyCloudflareConfigurationKeys(value)) {
|
||||
return "Cloudflare TURN configuration contains an unsupported field.";
|
||||
}
|
||||
|
||||
export function validateCloudflareTurnConfiguration(value: CloudflareTurnConfiguration): string | undefined {
|
||||
const turnKeyId = value.turnKeyId;
|
||||
if (typeof turnKeyId !== "string" || turnKeyId.length === 0) {
|
||||
return "Enter a TURN Key ID.";
|
||||
@@ -68,20 +47,3 @@ export function validateCloudflareIceServerSourceConfiguration(value: unknown):
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an untrusted profile value into a validated source configuration.
|
||||
* The returned object is a fresh copy so later settings mutations cannot
|
||||
* change a source which is already being used by the P2P owner.
|
||||
*/
|
||||
export function parseCloudflareIceServerSourceConfiguration(
|
||||
value: unknown
|
||||
): CloudflareIceServerSourceConfiguration | undefined {
|
||||
if (validateCloudflareIceServerSourceConfiguration(value) !== undefined || !isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
turnKeyId: value.turnKeyId as string,
|
||||
apiToken: value.apiToken as string,
|
||||
};
|
||||
}
|
||||
|
||||
+128
-149
@@ -1,18 +1,15 @@
|
||||
import { IceServerSourceError } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import type { IceServerConfiguration, IceServerSource } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import {
|
||||
CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT,
|
||||
CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS,
|
||||
parseCloudflareIceServerSourceConfiguration,
|
||||
type CloudflareIceServerSourceConfiguration,
|
||||
validateCloudflareIceServerSourceConfiguration,
|
||||
type CloudflareTurnConfiguration,
|
||||
validateCloudflareTurnConfiguration,
|
||||
} from "./settings";
|
||||
|
||||
/** Fetch-compatible function supplied by the host composition. */
|
||||
export type CloudflareIceServerSourceFetch = (input: string | Request, init?: RequestInit) => Promise<Response>;
|
||||
export type CloudflareTurnFetch = (input: string | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export interface CloudflareIceServerSourceDependencies {
|
||||
readonly fetch: CloudflareIceServerSourceFetch;
|
||||
export interface CloudflareTurnDependencies {
|
||||
readonly fetch: CloudflareTurnFetch;
|
||||
readonly now?: () => number;
|
||||
readonly requestDeadlineMs?: number;
|
||||
}
|
||||
@@ -21,19 +18,19 @@ export const CLOUDFLARE_TURN_REQUEST_DEADLINE_MS = 15_000 as const;
|
||||
export const CLOUDFLARE_TURN_MAX_RESPONSE_BYTES = 32 * 1024;
|
||||
export const CLOUDFLARE_TURN_MAX_ICE_SERVER_ENTRIES = 16 as const;
|
||||
export const CLOUDFLARE_TURN_MAX_ICE_SERVER_URLS = 32 as const;
|
||||
export const CLOUDFLARE_TURN_MIN_REMAINING_LIFETIME_MS = 1_000 as const;
|
||||
export const CLOUDFLARE_TURN_MIN_REMAINING_LIFETIME_MS = 30_000 as const;
|
||||
|
||||
type IceServerSourceFailureCode = "configuration" | "authentication" | "unavailable" | "invalid-response";
|
||||
type TurnFailureCode = "configuration" | "authentication" | "unavailable" | "invalid-response";
|
||||
|
||||
const SOURCE_FAILURE_MESSAGES: Record<IceServerSourceFailureCode, string> = {
|
||||
configuration: "The Cloudflare TURN source configuration is invalid.",
|
||||
const FAILURE_MESSAGES: Record<TurnFailureCode, string> = {
|
||||
configuration: "The Cloudflare TURN configuration is invalid.",
|
||||
authentication: "The Cloudflare TURN credential request was not authorised.",
|
||||
unavailable: "The Cloudflare TURN service is unavailable.",
|
||||
"invalid-response": "The Cloudflare TURN service returned an invalid response.",
|
||||
};
|
||||
|
||||
function sourceFailure(code: IceServerSourceFailureCode, retryable: boolean): IceServerSourceError {
|
||||
return new IceServerSourceError(code, SOURCE_FAILURE_MESSAGES[code], retryable);
|
||||
function credentialFailure(code: TurnFailureCode, retryable: boolean): Error {
|
||||
return Object.assign(new Error(FAILURE_MESSAGES[code]), { code, retryable });
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -123,10 +120,10 @@ function isCredential(value: unknown): value is string {
|
||||
|
||||
function normaliseIceServers(value: unknown): readonly RTCIceServer[] {
|
||||
if (!isRecord(value) || !Array.isArray(value.iceServers)) {
|
||||
throw sourceFailure("invalid-response", false);
|
||||
throw credentialFailure("invalid-response", false);
|
||||
}
|
||||
if (value.iceServers.length === 0 || value.iceServers.length > CLOUDFLARE_TURN_MAX_ICE_SERVER_ENTRIES) {
|
||||
throw sourceFailure("invalid-response", false);
|
||||
throw credentialFailure("invalid-response", false);
|
||||
}
|
||||
|
||||
const servers: RTCIceServer[] = [];
|
||||
@@ -134,7 +131,7 @@ function normaliseIceServers(value: unknown): readonly RTCIceServer[] {
|
||||
let hasTurnServer = false;
|
||||
|
||||
for (const candidate of value.iceServers) {
|
||||
if (!isRecord(candidate)) throw sourceFailure("invalid-response", false);
|
||||
if (!isRecord(candidate)) throw credentialFailure("invalid-response", false);
|
||||
const rawUrls = candidate.urls;
|
||||
const urls =
|
||||
typeof rawUrls === "string"
|
||||
@@ -142,11 +139,11 @@ function normaliseIceServers(value: unknown): readonly RTCIceServer[] {
|
||||
: Array.isArray(rawUrls) && rawUrls.every((url): url is string => typeof url === "string")
|
||||
? [...rawUrls]
|
||||
: undefined;
|
||||
if (!urls || urls.length === 0) throw sourceFailure("invalid-response", false);
|
||||
if (!urls || urls.length === 0) throw credentialFailure("invalid-response", false);
|
||||
|
||||
urlCount += urls.length;
|
||||
if (urlCount > CLOUDFLARE_TURN_MAX_ICE_SERVER_URLS || urls.some((url) => !isSupportedIceServerUrl(url))) {
|
||||
throw sourceFailure("invalid-response", false);
|
||||
throw credentialFailure("invalid-response", false);
|
||||
}
|
||||
|
||||
const turnEntry = urls.some(isTurnUrl);
|
||||
@@ -154,7 +151,7 @@ function normaliseIceServers(value: unknown): readonly RTCIceServer[] {
|
||||
const normalised: RTCIceServer = { urls };
|
||||
if (turnEntry) {
|
||||
if (!isCredential(candidate.username) || !isCredential(candidate.credential)) {
|
||||
throw sourceFailure("invalid-response", false);
|
||||
throw credentialFailure("invalid-response", false);
|
||||
}
|
||||
normalised.username = candidate.username;
|
||||
normalised.credential = candidate.credential;
|
||||
@@ -162,7 +159,7 @@ function normaliseIceServers(value: unknown): readonly RTCIceServer[] {
|
||||
servers.push(normalised);
|
||||
}
|
||||
|
||||
if (!hasTurnServer) throw sourceFailure("invalid-response", false);
|
||||
if (!hasTurnServer) throw credentialFailure("invalid-response", false);
|
||||
return Object.freeze(servers);
|
||||
}
|
||||
|
||||
@@ -233,14 +230,14 @@ async function readResponseBody(response: Response): Promise<string> {
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
function classifyHttpFailure(status: number): IceServerSourceError {
|
||||
function classifyHttpFailure(status: number): Error {
|
||||
if (status === 401 || status === 403) {
|
||||
return sourceFailure("authentication", false);
|
||||
return credentialFailure("authentication", false);
|
||||
}
|
||||
if (status === 408 || status === 429 || status >= 500) {
|
||||
return sourceFailure("unavailable", true);
|
||||
return credentialFailure("unavailable", true);
|
||||
}
|
||||
return sourceFailure("unavailable", false);
|
||||
return credentialFailure("unavailable", false);
|
||||
}
|
||||
|
||||
function parseResponseBody(body: string): readonly RTCIceServer[] {
|
||||
@@ -248,137 +245,119 @@ function parseResponseBody(body: string): readonly RTCIceServer[] {
|
||||
try {
|
||||
value = JSON.parse(body) as unknown;
|
||||
} catch {
|
||||
throw sourceFailure("invalid-response", false);
|
||||
throw credentialFailure("invalid-response", false);
|
||||
}
|
||||
return normaliseIceServers(value);
|
||||
}
|
||||
|
||||
function createSource(
|
||||
configuration: CloudflareIceServerSourceConfiguration,
|
||||
dependencies: CloudflareIceServerSourceDependencies
|
||||
): IceServerSource {
|
||||
/** Acquire one temporary ICE configuration for a new room connection. */
|
||||
export async function acquireCloudflareTurnCredentials(
|
||||
configuration: CloudflareTurnConfiguration,
|
||||
dependencies: CloudflareTurnDependencies,
|
||||
signal: AbortSignal
|
||||
): Promise<{ iceServers: readonly RTCIceServer[]; expiresAt: number }> {
|
||||
if (validateCloudflareTurnConfiguration(configuration)) throw credentialFailure("configuration", false);
|
||||
const now = dependencies.now ?? Date.now;
|
||||
const requestDeadlineMs = dependencies.requestDeadlineMs ?? CLOUDFLARE_TURN_REQUEST_DEADLINE_MS;
|
||||
throwIfAborted(signal);
|
||||
const requestStartedAt = now();
|
||||
if (!Number.isFinite(requestStartedAt)) {
|
||||
throw credentialFailure("unavailable", true);
|
||||
}
|
||||
|
||||
return {
|
||||
async acquire(signal: AbortSignal): Promise<IceServerConfiguration> {
|
||||
throwIfAborted(signal);
|
||||
const requestStartedAt = now();
|
||||
if (!Number.isFinite(requestStartedAt)) {
|
||||
throw sourceFailure("unavailable", true);
|
||||
}
|
||||
|
||||
const requestController = new AbortController();
|
||||
let cancelledByCaller = false;
|
||||
let rejectCaller: ((reason?: unknown) => void) | undefined;
|
||||
const callerAbort = new Promise<never>((_resolve, reject) => {
|
||||
rejectCaller = reject;
|
||||
});
|
||||
let timedOut = false;
|
||||
const onAbort = () => {
|
||||
cancelledByCaller = true;
|
||||
requestController.abort();
|
||||
rejectCaller?.(abortError());
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal.aborted) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
requestController.abort();
|
||||
throw abortError();
|
||||
}
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = new Promise<never>((_resolve, reject) => {
|
||||
timeoutId = globalThis.setTimeout(() => {
|
||||
timedOut = true;
|
||||
requestController.abort();
|
||||
reject(sourceFailure("unavailable", true));
|
||||
}, requestDeadlineMs);
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId !== undefined) globalThis.clearTimeout(timeoutId);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
|
||||
const endpoint = `${CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT}/${configuration.turnKeyId}/credentials/generate-ice-servers`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await Promise.race([
|
||||
dependencies.fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${configuration.apiToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ttl: CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS }),
|
||||
signal: requestController.signal,
|
||||
redirect: "error",
|
||||
credentials: "omit",
|
||||
cache: "no-store",
|
||||
}),
|
||||
callerAbort,
|
||||
deadline,
|
||||
]);
|
||||
} catch {
|
||||
cleanup();
|
||||
if (cancelledByCaller || signal.aborted) throw abortError();
|
||||
if (timedOut) throw sourceFailure("unavailable", true);
|
||||
throw sourceFailure("unavailable", true);
|
||||
}
|
||||
|
||||
if (cancelledByCaller || signal.aborted) {
|
||||
cleanup();
|
||||
throw abortError();
|
||||
}
|
||||
if (timedOut || requestController.signal.aborted) {
|
||||
cleanup();
|
||||
throw sourceFailure("unavailable", true);
|
||||
}
|
||||
if (response.status !== 201) {
|
||||
cleanup();
|
||||
throw classifyHttpFailure(response.status);
|
||||
}
|
||||
|
||||
let body: string;
|
||||
try {
|
||||
body = await Promise.race([readResponseBody(response), callerAbort, deadline]);
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
if (cancelledByCaller || signal.aborted) throw abortError();
|
||||
if (timedOut) throw sourceFailure("unavailable", true);
|
||||
if (error instanceof BoundedResponseError && error.kind === "read-failed") {
|
||||
throw sourceFailure("unavailable", true);
|
||||
}
|
||||
throw sourceFailure("invalid-response", false);
|
||||
}
|
||||
|
||||
try {
|
||||
throwIfAborted(signal);
|
||||
const iceServers = parseResponseBody(body);
|
||||
const expiresAt = requestStartedAt + CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS * 1_000;
|
||||
if (!Number.isFinite(expiresAt) || expiresAt <= now() + CLOUDFLARE_TURN_MIN_REMAINING_LIFETIME_MS) {
|
||||
throw sourceFailure("invalid-response", false);
|
||||
}
|
||||
return { iceServers, expiresAt };
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
},
|
||||
const requestController = new AbortController();
|
||||
let cancelledByCaller = false;
|
||||
let rejectCaller: ((reason?: unknown) => void) | undefined;
|
||||
const callerAbort = new Promise<never>((_resolve, reject) => {
|
||||
rejectCaller = reject;
|
||||
});
|
||||
let timedOut = false;
|
||||
const onAbort = () => {
|
||||
cancelledByCaller = true;
|
||||
requestController.abort();
|
||||
rejectCaller?.(abortError());
|
||||
};
|
||||
}
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal.aborted) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
requestController.abort();
|
||||
throw abortError();
|
||||
}
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = new Promise<never>((_resolve, reject) => {
|
||||
timeoutId = globalThis.setTimeout(() => {
|
||||
timedOut = true;
|
||||
requestController.abort();
|
||||
reject(credentialFailure("unavailable", true));
|
||||
}, requestDeadlineMs);
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a Cloudflare source after validating its persisted configuration.
|
||||
* Validation is synchronous and performs no network request.
|
||||
*/
|
||||
export function createCloudflareIceServerSource(
|
||||
configuration: Readonly<Record<string, unknown>>,
|
||||
dependencies: CloudflareIceServerSourceDependencies
|
||||
): IceServerSource {
|
||||
const parsed = parseCloudflareIceServerSourceConfiguration(configuration);
|
||||
if (!parsed) throw sourceFailure("configuration", false);
|
||||
return createSource(parsed, dependencies);
|
||||
}
|
||||
const cleanup = () => {
|
||||
if (timeoutId !== undefined) globalThis.clearTimeout(timeoutId);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
|
||||
/** Exposes the provider validation for the integration catalogue and UI. */
|
||||
export { validateCloudflareIceServerSourceConfiguration };
|
||||
const endpoint = `${CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT}/${configuration.turnKeyId}/credentials/generate-ice-servers`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await Promise.race([
|
||||
dependencies.fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${configuration.apiToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ttl: CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS }),
|
||||
signal: requestController.signal,
|
||||
redirect: "error",
|
||||
credentials: "omit",
|
||||
cache: "no-store",
|
||||
}),
|
||||
callerAbort,
|
||||
deadline,
|
||||
]);
|
||||
} catch {
|
||||
cleanup();
|
||||
if (cancelledByCaller || signal.aborted) throw abortError();
|
||||
if (timedOut) throw credentialFailure("unavailable", true);
|
||||
throw credentialFailure("unavailable", true);
|
||||
}
|
||||
|
||||
if (cancelledByCaller || signal.aborted) {
|
||||
cleanup();
|
||||
throw abortError();
|
||||
}
|
||||
if (timedOut || requestController.signal.aborted) {
|
||||
cleanup();
|
||||
throw credentialFailure("unavailable", true);
|
||||
}
|
||||
if (response.status !== 201) {
|
||||
cleanup();
|
||||
throw classifyHttpFailure(response.status);
|
||||
}
|
||||
|
||||
let body: string;
|
||||
try {
|
||||
body = await Promise.race([readResponseBody(response), callerAbort, deadline]);
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
if (cancelledByCaller || signal.aborted) throw abortError();
|
||||
if (timedOut) throw credentialFailure("unavailable", true);
|
||||
if (error instanceof BoundedResponseError && error.kind === "read-failed") {
|
||||
throw credentialFailure("unavailable", true);
|
||||
}
|
||||
throw credentialFailure("invalid-response", false);
|
||||
}
|
||||
|
||||
try {
|
||||
throwIfAborted(signal);
|
||||
const iceServers = parseResponseBody(body);
|
||||
const expiresAt = requestStartedAt + CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS * 1_000;
|
||||
if (!Number.isFinite(expiresAt) || expiresAt <= now() + CLOUDFLARE_TURN_MIN_REMAINING_LIFETIME_MS) {
|
||||
throw credentialFailure("invalid-response", false);
|
||||
}
|
||||
return { iceServers, expiresAt };
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
+49
-34
@@ -2,12 +2,12 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CLOUDFLARE_TURN_MAX_RESPONSE_BYTES,
|
||||
CLOUDFLARE_TURN_REQUEST_DEADLINE_MS,
|
||||
createCloudflareIceServerSource,
|
||||
} from "./iceServerSource";
|
||||
acquireCloudflareTurnCredentials,
|
||||
} from "./turnCredentials";
|
||||
import {
|
||||
CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT,
|
||||
CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS,
|
||||
validateCloudflareIceServerSourceConfiguration,
|
||||
validateCloudflareTurnConfiguration,
|
||||
} from "./settings";
|
||||
|
||||
const configuration = {
|
||||
@@ -39,7 +39,7 @@ afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Cloudflare ICE server source", () => {
|
||||
describe("Cloudflare TURN credentials", () => {
|
||||
it("requests the fixed endpoint with the bearer token and TTL", async () => {
|
||||
const now = 1_000_000;
|
||||
let requestUrl: string | Request | undefined;
|
||||
@@ -49,9 +49,13 @@ describe("Cloudflare ICE server source", () => {
|
||||
requestInit = init;
|
||||
return response(validBody());
|
||||
});
|
||||
const source = createCloudflareIceServerSource(configuration, { fetch, now: () => now });
|
||||
const dependencies = { fetch, now: () => now };
|
||||
|
||||
const result = await source.acquire(new AbortController().signal);
|
||||
const result = await acquireCloudflareTurnCredentials(
|
||||
configuration,
|
||||
dependencies,
|
||||
new AbortController().signal
|
||||
);
|
||||
|
||||
expect(requestUrl).toBe(`${CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT}/key-123/credentials/generate-ice-servers`);
|
||||
expect(requestInit).toMatchObject({
|
||||
@@ -75,38 +79,50 @@ describe("Cloudflare ICE server source", () => {
|
||||
{ body: { iceServers: [{ urls: "stun:stun.example.test:3478" }] }, expectedCode: "invalid-response" },
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const source = createCloudflareIceServerSource(configuration, {
|
||||
const dependencies = {
|
||||
fetch: vi.fn(async () => response(testCase.body)),
|
||||
now: () => 1_000_000,
|
||||
});
|
||||
const error = await source.acquire(new AbortController().signal).catch((reason: unknown) => reason);
|
||||
};
|
||||
const error = await acquireCloudflareTurnCredentials(
|
||||
configuration,
|
||||
dependencies,
|
||||
new AbortController().signal
|
||||
).catch((reason: unknown) => reason);
|
||||
expect(error).toMatchObject({ code: testCase.expectedCode });
|
||||
expect(String(error)).not.toContain(configuration.apiToken);
|
||||
expect(String(error)).not.toContain(configuration.turnKeyId);
|
||||
}
|
||||
|
||||
const oversized = "x".repeat(CLOUDFLARE_TURN_MAX_RESPONSE_BYTES + 1);
|
||||
const source = createCloudflareIceServerSource(configuration, {
|
||||
const dependencies = {
|
||||
fetch: vi.fn(async () => new Response(oversized, { status: 201 })),
|
||||
now: () => 1_000_000,
|
||||
});
|
||||
const error = await source.acquire(new AbortController().signal).catch((reason: unknown) => reason);
|
||||
};
|
||||
const error = await acquireCloudflareTurnCredentials(
|
||||
configuration,
|
||||
dependencies,
|
||||
new AbortController().signal
|
||||
).catch((reason: unknown) => reason);
|
||||
expect(error).toMatchObject({ code: "invalid-response" });
|
||||
});
|
||||
|
||||
it("classifies authentication and transient provider failures", async () => {
|
||||
const authSource = createCloudflareIceServerSource(configuration, {
|
||||
const authDependencies = {
|
||||
fetch: vi.fn(async () => response({}, 401)),
|
||||
});
|
||||
await expect(authSource.acquire(new AbortController().signal)).rejects.toMatchObject({
|
||||
};
|
||||
await expect(
|
||||
acquireCloudflareTurnCredentials(configuration, authDependencies, new AbortController().signal)
|
||||
).rejects.toMatchObject({
|
||||
code: "authentication",
|
||||
retryable: false,
|
||||
});
|
||||
|
||||
const transientSource = createCloudflareIceServerSource(configuration, {
|
||||
const transientDependencies = {
|
||||
fetch: vi.fn(async () => response({}, 503)),
|
||||
});
|
||||
await expect(transientSource.acquire(new AbortController().signal)).rejects.toMatchObject({
|
||||
};
|
||||
await expect(
|
||||
acquireCloudflareTurnCredentials(configuration, transientDependencies, new AbortController().signal)
|
||||
).rejects.toMatchObject({
|
||||
code: "unavailable",
|
||||
retryable: true,
|
||||
});
|
||||
@@ -121,14 +137,14 @@ describe("Cloudflare ICE server source", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
const source = createCloudflareIceServerSource(configuration, { fetch });
|
||||
const cancelled = source.acquire(controller.signal);
|
||||
const dependencies = { fetch };
|
||||
const cancelled = acquireCloudflareTurnCredentials(configuration, dependencies, controller.signal);
|
||||
controller.abort();
|
||||
await expect(cancelled).rejects.toMatchObject({ name: "AbortError" });
|
||||
|
||||
vi.useFakeTimers();
|
||||
const timedSource = createCloudflareIceServerSource(configuration, { fetch });
|
||||
const timed = timedSource.acquire(new AbortController().signal);
|
||||
const timedDependencies = { fetch };
|
||||
const timed = acquireCloudflareTurnCredentials(configuration, timedDependencies, new AbortController().signal);
|
||||
const assertion = expect(timed).rejects.toMatchObject({ code: "unavailable", retryable: true });
|
||||
await vi.advanceTimersByTimeAsync(CLOUDFLARE_TURN_REQUEST_DEADLINE_MS);
|
||||
await assertion;
|
||||
@@ -136,29 +152,28 @@ describe("Cloudflare ICE server source", () => {
|
||||
|
||||
it("rejects an issuance which has no usable remaining lifetime", async () => {
|
||||
let now = 1_000_000;
|
||||
const source = createCloudflareIceServerSource(configuration, {
|
||||
const dependencies = {
|
||||
fetch: vi.fn(async () => {
|
||||
now += CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS * 1_000;
|
||||
return response(validBody());
|
||||
}),
|
||||
now: () => now,
|
||||
});
|
||||
await expect(source.acquire(new AbortController().signal)).rejects.toMatchObject({
|
||||
};
|
||||
await expect(
|
||||
acquireCloudflareTurnCredentials(configuration, dependencies, new AbortController().signal)
|
||||
).rejects.toMatchObject({
|
||||
code: "invalid-response",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cloudflare ICE source validation", () => {
|
||||
it("rejects unknown fields and malformed bearer credentials", () => {
|
||||
expect(validateCloudflareIceServerSourceConfiguration({ ...configuration, unexpected: "value" })).toContain(
|
||||
"unsupported field"
|
||||
);
|
||||
describe("Cloudflare TURN input validation", () => {
|
||||
it("rejects unsafe key IDs and malformed bearer credentials", () => {
|
||||
expect(
|
||||
validateCloudflareIceServerSourceConfiguration({ turnKeyId: "key/id", apiToken: configuration.apiToken })
|
||||
validateCloudflareTurnConfiguration({ turnKeyId: "key/id", apiToken: configuration.apiToken })
|
||||
).toContain("unsupported characters");
|
||||
expect(
|
||||
validateCloudflareIceServerSourceConfiguration({ ...configuration, apiToken: "token with spaces" })
|
||||
).toContain("Bearer token syntax");
|
||||
expect(validateCloudflareTurnConfiguration({ ...configuration, apiToken: "token with spaces" })).toContain(
|
||||
"Bearer token syntax"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { CLOUDFLARE_ICE_SERVER_SOURCE_ID, validateCloudflareIceServerSourceConfiguration } from "./cloudflare/settings";
|
||||
|
||||
export const MANUAL_ICE_SERVER_SOURCE_ID = "manual" as const;
|
||||
|
||||
export type IceServerSourceSelectionId = typeof MANUAL_ICE_SERVER_SOURCE_ID | typeof CLOUDFLARE_ICE_SERVER_SOURCE_ID;
|
||||
|
||||
export interface IceServerSourceFieldDefinition {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly secret: boolean;
|
||||
}
|
||||
|
||||
export interface IceServerSourceDefinition {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly fields: readonly IceServerSourceFieldDefinition[];
|
||||
}
|
||||
|
||||
export interface IceServerSourceDescriptorLike {
|
||||
readonly version?: unknown;
|
||||
readonly id?: unknown;
|
||||
readonly configuration?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* The service-owned field metadata used by the P2P settings dialogue. Manual
|
||||
* TURN values remain the existing settings fields and therefore do not occur
|
||||
* in this provider catalogue.
|
||||
*/
|
||||
export const iceServerSourceDefinitions = [
|
||||
{
|
||||
id: CLOUDFLARE_ICE_SERVER_SOURCE_ID,
|
||||
label: "Cloudflare",
|
||||
fields: [
|
||||
{ key: "turnKeyId", label: "TURN Key ID", secret: false },
|
||||
{ key: "apiToken", label: "TURN Key API Token", secret: true },
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly IceServerSourceDefinition[];
|
||||
|
||||
/** The user-facing source choice, including the existing manual mode. */
|
||||
export const turnConfigurationChoices = [
|
||||
{ id: MANUAL_ICE_SERVER_SOURCE_ID, label: "Manual" },
|
||||
{ id: CLOUDFLARE_ICE_SERVER_SOURCE_ID, label: "Cloudflare" },
|
||||
] as const;
|
||||
|
||||
export const iceServerSourceChoices = turnConfigurationChoices;
|
||||
|
||||
function isRecord(value: unknown): value is IceServerSourceDescriptorLike {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a selected source descriptor without performing network access.
|
||||
* An absent descriptor represents the existing manual TURN configuration.
|
||||
*/
|
||||
export function validateIceServerSourceConfiguration(
|
||||
descriptor: IceServerSourceDescriptorLike | null | undefined
|
||||
): string | undefined {
|
||||
if (descriptor === undefined || descriptor === null) return undefined;
|
||||
if (!isRecord(descriptor)) return "TURN configuration source is invalid.";
|
||||
if (descriptor.version !== 1) return "TURN configuration source version is not supported.";
|
||||
if (descriptor.id === MANUAL_ICE_SERVER_SOURCE_ID) {
|
||||
return undefined;
|
||||
}
|
||||
if (descriptor.id !== CLOUDFLARE_ICE_SERVER_SOURCE_ID) {
|
||||
return "The selected TURN configuration source is not supported.";
|
||||
}
|
||||
return validateCloudflareIceServerSourceConfiguration(descriptor.configuration);
|
||||
}
|
||||
|
||||
export function getIceServerSourceDefinition(id: string): IceServerSourceDefinition | undefined {
|
||||
return iceServerSourceDefinitions.find((definition) => definition.id === id);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
iceServerSourceDefinitions,
|
||||
validateIceServerSourceConfiguration,
|
||||
} from "./iceServerSources";
|
||||
|
||||
describe("ICE server source catalogue", () => {
|
||||
it("describes the Cloudflare fields without owning manual TURN fields", () => {
|
||||
expect(iceServerSourceDefinitions).toEqual([
|
||||
{
|
||||
id: "cloudflare",
|
||||
label: "Cloudflare",
|
||||
fields: [
|
||||
{ key: "turnKeyId", label: "TURN Key ID", secret: false },
|
||||
{ key: "apiToken", label: "TURN Key API Token", secret: true },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts absent or explicit manual selection and rejects unsupported versions", () => {
|
||||
expect(validateIceServerSourceConfiguration(undefined)).toBeUndefined();
|
||||
expect(validateIceServerSourceConfiguration({ version: 1, id: "manual" })).toBeUndefined();
|
||||
expect(validateIceServerSourceConfiguration({ version: 2, id: "cloudflare", configuration: {} })).toContain(
|
||||
"version"
|
||||
);
|
||||
expect(validateIceServerSourceConfiguration({ version: 1, id: "unknown", configuration: {} })).toContain(
|
||||
"not supported"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { P2PConnectionInfo } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { CLOUDFLARE_TURN_TYPE, validateCloudflareTurnConfiguration } from "./cloudflare/settings";
|
||||
|
||||
/** Validate provider inputs without requesting credentials. */
|
||||
export function validateManagedTurnSettings(settings: Partial<P2PConnectionInfo>): string | undefined {
|
||||
if (settings.P2P_managedType === undefined || settings.P2P_managedType === "") return undefined;
|
||||
if (settings.P2P_managedType !== CLOUDFLARE_TURN_TYPE) {
|
||||
return "The selected TURN configuration is not supported.";
|
||||
}
|
||||
return validateCloudflareTurnConfiguration({
|
||||
turnKeyId: settings.P2P_managedId ?? "",
|
||||
apiToken: settings.P2P_managedToken ?? "",
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user