Use host preparation for TURN connection settings

This commit is contained in:
vorotamoroz
2026-09-16 03:43:09 +00:00
parent a565070809
commit 11a07b26af
33 changed files with 654 additions and 961 deletions
@@ -5,7 +5,7 @@
import type { P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { P2PReplicatorPaneHost } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost";
import TurnConfiguration from "@/features/P2PSync/TurnConfiguration.svelte";
import { validateIceServerSourceConfiguration } from "@/integrations/iceServerSources";
import { validateManagedTurnSettings } from "@/integrations/turnSettings";
let { host }: { host: P2PReplicatorPaneHost } = $props();
const currentSettings = () => host.services.setting.currentSettings() as P2PSyncSetting;
@@ -15,21 +15,23 @@
P2P_turnServers: settings.P2P_turnServers,
P2P_turnUsername: settings.P2P_turnUsername,
P2P_turnCredential: settings.P2P_turnCredential,
P2P_iceServerSource: structuredClone(settings.P2P_iceServerSource),
P2P_managedType: settings.P2P_managedType,
P2P_managedId: settings.P2P_managedId,
P2P_managedToken: settings.P2P_managedToken,
};
}
let draft = $state(turnSettings(currentSettings()));
let saved = $state(JSON.stringify(turnSettings(currentSettings())));
const isModified = $derived(JSON.stringify(draft) !== saved);
const sourceError = $derived(validateIceServerSourceConfiguration(draft.P2P_iceServerSource));
const sourceNeedsRoom = $derived(!!draft.P2P_iceServerSource && (draft.P2P_roomID ?? "").trim() === "");
const sourceError = $derived(validateManagedTurnSettings(draft));
const sourceNeedsRoom = $derived(!!draft.P2P_managedType && (draft.P2P_roomID ?? "").trim() === "");
function loadSettings(settings: P2PSyncSetting): void {
const next = turnSettings(settings);
draft = next;
saved = JSON.stringify(next);
}
onMount(() => host.services.context.events.onEvent("setting-saved", (settings) => loadSettings(settings as P2PSyncSetting)));
onMount(() => host.services.context.events.onEvent("setting-saved", () => loadSettings(currentSettings())));
async function save(): Promise<void> {
if (sourceError || sourceNeedsRoom) return;
@@ -38,8 +40,11 @@
const next = { ...settings, ...values, remoteConfigurations: { ...settings.remoteConfigurations } };
const profileId = settings.P2P_ActiveRemoteConfigurationId ||
(settings.remoteType === REMOTE_P2P ? settings.activeConfigurationId : "");
if (profileId && next.remoteConfigurations[profileId]) {
upsertRemoteConfigurationInPlace(next, "p2p", { id: profileId });
const selected = next.remoteConfigurations[profileId];
if (selected?.uri.startsWith("sls+p2p://")) {
upsertRemoteConfigurationInPlace(next, "p2p", { id: profileId, activateForP2P: true });
} else if (values.P2P_managedType) {
upsertRemoteConfigurationInPlace(next, "p2p", { activateForP2P: true });
}
return next;
}, true);
@@ -421,16 +421,15 @@ describe("runCommand abnormal cases", () => {
it("setup imports managed TURN through the existing encrypted URI", async () => {
const core = createCoreMock();
const source = {
version: 1,
id: "cloudflare",
configuration: { turnKeyId: "turn-key", apiToken: "private-token" },
const profiles = {
turn: { id: "turn", name: "TURN", isEncrypted: false,
uri: "sls+p2p://room?managedType=CF&managedId=turn-key&token=private-token" },
};
const passphrase = "setup-passphrase";
const setupURI = await processSetting.encodeSettingsToSetupURI(
{
...DEFAULT_SETTINGS,
P2P_iceServerSource: source,
remoteConfigurations: profiles,
},
passphrase
);
@@ -439,7 +438,7 @@ describe("runCommand abnormal cases", () => {
core.services.context.standardIo.prompt.mockResolvedValue(passphrase);
await runCommand(makeOptions("setup", [setupURI]), { ...context, core });
expect(core.services.setting.applyExternalSettings).toHaveBeenCalledWith(
expect.objectContaining({ P2P_iceServerSource: source }),
expect.objectContaining({ remoteConfigurations: profiles }),
true
);
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { useIceServerSources } from "@/serviceFeatures/useIceServerSources";
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub";
import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./services/NodeLocalStorage";
import { LiveSyncBaseCore, type StartupDatabaseOptions } from "@/LiveSyncBaseCore";
@@ -526,7 +526,7 @@ export async function main(
}
// Register P2P replicator feature.
p2pReplicator = useP2PReplicatorFeature(core, undefined, undefined, {
iceServerSources: useIceServerSources(core.services.API.webCompatFetch.bind(core.services.API)),
prepareP2PSettings: useP2PSettingsPreparation(core.services.API.webCompatFetch.bind(core.services.API)),
});
// Add target filter to prevent internal files are handled
core.services.vault.isTargetFile.addHandler(async (target) => {
+2 -2
View File
@@ -1,4 +1,4 @@
import { useIceServerSources } from "@/serviceFeatures/useIceServerSources";
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
/** Browser runtime for Self-hosted LiveSync over the File System Access API. */
import { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
@@ -219,7 +219,7 @@ export class WebAppRuntime {
useCheckRemoteSize(core);
useRemoteConfiguration(core);
this.p2p = useP2PReplicatorFeature(core, undefined, undefined, {
iceServerSources: useIceServerSources(core.services.API.webCompatFetch.bind(core.services.API)),
prepareP2PSettings: useP2PSettingsPreparation(core.services.API.webCompatFetch.bind(core.services.API)),
});
this.paneHost = {
services: core.services,
+2 -2
View File
@@ -1,4 +1,4 @@
import { useIceServerSources } from "@/serviceFeatures/useIceServerSources";
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
import { type P2PSyncSetting, SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
@@ -72,7 +72,7 @@ export class WebPeerRuntime {
},
});
this.p2p = useP2PReplicatorFeature({ services: this.services, serviceModules: {} }, undefined, undefined, {
iceServerSources: useIceServerSources(this.services.API.webCompatFetch.bind(this.services.API)),
prepareP2PSettings: useP2PSettingsPreparation(this.services.API.webCompatFetch.bind(this.services.API)),
});
this.p2pLogCollector = new P2PLogCollector(this.events);
this.paneHost = {
@@ -21,17 +21,11 @@ export const liveSyncProvisionalEnglishMessages = {
"TURN relay only requires a TURN server or a configured credential source under Advanced Settings.",
"TURN relay only requires TURN configuration. Connection path has been restored to Automatic.":
"TURN relay only requires TURN configuration. Connection path has been restored to Automatic.",
"Cloudflare TURN configuration is invalid.": "Cloudflare TURN configuration is invalid.",
"Cloudflare TURN configuration contains an unsupported field.":
"Cloudflare TURN configuration contains an unsupported field.",
"Enter a TURN Key ID.": "Enter a TURN Key ID.",
"TURN Key ID contains unsupported characters.": "TURN Key ID contains unsupported characters.",
"Enter a TURN Key API Token.": "Enter a TURN Key API Token.",
"TURN Key API Token must use Bearer token syntax.": "TURN Key API Token must use Bearer token syntax.",
"TURN configuration source version is not supported.": "TURN configuration source version is not supported.",
"TURN configuration source is invalid.": "TURN configuration source is invalid.",
"The selected TURN configuration source is not supported.":
"The selected TURN configuration source is not supported.",
"The selected TURN configuration is not supported.": "The selected TURN configuration is not supported.",
"Setup Complete: Preparing to Fetch from Another Device": "Setup Complete: Preparing to Fetch from Another Device",
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.":
+2 -2
View File
@@ -1,4 +1,4 @@
import { redactTurnSourceForReport } from "./turnSettingsPrivacy";
import { redactTurnSettingsForReport } from "./turnSettingsPrivacy";
import { REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/settings";
import { generateCredentialObject } from "@vrtmrz/livesync-commonlib/compat/replication/httplib";
@@ -68,7 +68,7 @@ export async function generateReport(settings: ObsidianLiveSyncSettings, core: L
delete pluginConfig[key as keyof ObsidianLiveSyncSettings];
}
redactTurnSourceForReport(pluginConfig);
redactTurnSettingsForReport(pluginConfig);
pluginConfig.couchDB_DBNAME = REDACTED;
pluginConfig.couchDB_PASSWORD = REDACTED;
const scheme = pluginConfig.couchDB_URI.startsWith("http:")
+8 -5
View File
@@ -10,19 +10,21 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({
}));
describe("TURN credentials in diagnostic reports", () => {
it("redacts top-level and inactive encoded source copies", async () => {
it("redacts provider tokens in all profiles and runtime credentials", async () => {
const token = "private+token/with=symbols";
const source = { version: 1, id: "cloudflare", configuration: { turnKeyId: "private-key", apiToken: token } };
const provider = { P2P_managedType: "CF", P2P_managedId: "private-key", P2P_managedToken: token };
const settings = {
...DEFAULT_SETTINGS,
remoteType: REMOTE_P2P,
P2P_iceServerSource: source,
...provider,
P2P_iceServers: [{ urls: "turn:example.test", username: "issued-user", credential: "issued-password" }],
P2P_iceServersExpiresAt: 123456789,
remoteConfigurations: {
inactive: {
id: "inactive",
name: "Inactive TURN",
isEncrypted: false,
uri: `sls+p2p://room?source=${encodeURIComponent(JSON.stringify(source))}`,
uri: `sls+p2p://room?managedType=CF&managedId=private-key&token=${encodeURIComponent(token)}`,
},
},
};
@@ -33,6 +35,7 @@ describe("TURN credentials in diagnostic reports", () => {
expect(text).not.toContain(encodeURIComponent(token));
expect(text).not.toContain("private-key");
expect(report.pluginConfig.remoteConfigurations.inactive.uri).toBe("sls+p2p://");
expect(settings.P2P_iceServerSource).toEqual(source);
expect(settings.P2P_managedToken).toBe(token);
expect(text).not.toMatch(/issued-user|issued-password|P2P_iceServers/);
});
});
+26 -22
View File
@@ -1,45 +1,50 @@
import {
hasManagedP2PIceServerSource,
hasManagedP2PTurnConfiguration,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { pickP2PSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { CLOUDFLARE_TURN_TYPE } from "@/integrations/cloudflare/settings";
import { iceServerSourceDefinitions } from "@/integrations/iceServerSources";
/** Include inactive profiles when deciding whether Markdown would disclose source settings. */
/** Include inactive profiles when deciding whether Markdown would disclose provider settings. */
export function hasManagedTurnSettings(settings: Partial<ObsidianLiveSyncSettings>): boolean {
return (
hasManagedP2PIceServerSource(settings) ||
hasManagedP2PTurnConfiguration(settings) ||
Object.values(settings.remoteConfigurations ?? {}).some(({ uri }) => {
if (!uri.startsWith("sls+p2p://")) return false;
const queryStart = uri.indexOf("?");
return queryStart >= 0 && new URLSearchParams(uri.slice(queryStart + 1).split("#", 1)[0]).has("source");
return (
queryStart >= 0 && new URLSearchParams(uri.slice(queryStart + 1).split("#", 1)[0]).has("managedType")
);
})
);
}
/** Reports retain the selected source label, but no opaque source configuration. */
export function redactTurnSourceForReport(settings: Partial<ObsidianLiveSyncSettings>): void {
if (settings.P2P_iceServerSource !== undefined) {
settings.P2P_iceServerSource = {
version: 1,
id:
iceServerSourceDefinitions.find((source) => source.id === settings.P2P_iceServerSource?.id)?.id ??
"redacted",
configuration: { redacted: true },
};
/** Reports retain a recognised provider label and omit issued credentials. */
export function redactTurnSettingsForReport(settings: Partial<ObsidianLiveSyncSettings>): void {
if (settings.P2P_managedType) {
settings.P2P_managedType =
settings.P2P_managedType === CLOUDFLARE_TURN_TYPE ? CLOUDFLARE_TURN_TYPE : "redacted";
}
if (settings.P2P_managedId !== undefined) settings.P2P_managedId = "redacted";
if (settings.P2P_managedToken !== undefined) settings.P2P_managedToken = "redacted";
delete settings.P2P_iceServers;
delete settings.P2P_iceServersExpiresAt;
}
/** Managed connection profiles are shared through Setup URIs and QR codes. */
export function omitManagedTurnProfilesFromMarkdown(settings: Partial<ObsidianLiveSyncSettings>): void {
delete settings.P2P_iceServers;
delete settings.P2P_iceServersExpiresAt;
if (!hasManagedTurnSettings(settings)) return;
delete settings.P2P_iceServerSource;
delete settings.P2P_managedType;
delete settings.P2P_managedId;
delete settings.P2P_managedToken;
delete settings.remoteConfigurations;
delete settings.activeConfigurationId;
delete settings.P2P_ActiveRemoteConfigurationId;
}
/** An omitted profile group leaves this device's existing connection selection intact. */
/** Preserve the complete connection when Markdown omits its profile group. */
export function preserveManagedTurnProfilesOnMarkdownImport(
incoming: Partial<ObsidianLiveSyncSettings>,
current: ObsidianLiveSyncSettings,
@@ -48,12 +53,11 @@ export function preserveManagedTurnProfilesOnMarkdownImport(
if (
!hasManagedTurnSettings(current) ||
incoming.remoteConfigurations !== undefined ||
incoming.P2P_iceServerSource !== undefined
) {
incoming.P2P_managedType !== undefined
)
return;
}
merged.remoteConfigurations = structuredClone(current.remoteConfigurations);
merged.activeConfigurationId = current.activeConfigurationId;
merged.P2P_ActiveRemoteConfigurationId = current.P2P_ActiveRemoteConfigurationId;
merged.P2P_iceServerSource = structuredClone(current.P2P_iceServerSource);
Object.assign(merged, pickP2PSyncSettings(current));
}
+84 -16
View File
@@ -1,26 +1,55 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
DEFAULT_SETTINGS,
REMOTE_P2P,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
SettingService,
type SettingServiceDependencies,
} from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import {
hasManagedTurnSettings,
omitManagedTurnProfilesFromMarkdown,
preserveManagedTurnProfilesOnMarkdownImport,
redactTurnSourceForReport,
redactTurnSettingsForReport,
} from "./turnSettingsPrivacy";
class MemorySettingService extends SettingService {
readonly items = new Map<string, string>();
saved?: ObsidianLiveSyncSettings;
protected setItem(key: string, value: string) {
this.items.set(key, value);
}
protected getItem(key: string) {
return this.items.get(key) ?? "";
}
protected deleteItem(key: string) {
this.items.delete(key);
}
protected saveData(settings: ObsidianLiveSyncSettings) {
this.saved = structuredClone(settings);
return Promise.resolve();
}
protected loadData() {
return Promise.resolve(this.saved);
}
}
function configuredSettings() {
return {
...DEFAULT_SETTINGS,
P2P_iceServerSource: {
version: 1,
id: "cloudflare",
configuration: { turnKeyId: "private-key-id", apiToken: "private-token" },
},
P2P_managedType: "CF",
P2P_managedId: "private-key-id",
P2P_managedToken: "private-token",
remoteConfigurations: {
managed: {
id: "managed",
name: "Managed TURN",
isEncrypted: false,
uri: "sls+p2p://room?source=private-token",
uri: "sls+p2p://room?managedType=CF&managedId=private-key-id&token=private-token",
},
},
activeConfigurationId: "central",
@@ -29,17 +58,56 @@ function configuredSettings() {
}
describe("managed TURN settings privacy", () => {
it("redacts all opaque source fields, including unknown integrations", () => {
it("preserves the active managed room through Markdown import, save, and reload", async () => {
const current = {
...configuredSettings(),
remoteType: REMOTE_P2P,
activeConfigurationId: "managed",
P2P_roomID: "local-room",
P2P_relays: "wss://local-relay.example.test",
P2P_passphrase: "local-passphrase",
};
const originalURI = ConnectionStringParser.serialize({ type: "p2p", settings: current });
current.remoteConfigurations.managed.uri = originalURI;
const service = new MemorySettingService(new ServiceContext(), {
APIService: {
getSystemVaultName: () => "test-vault",
getAppID: () => "test-app",
addLog: () => undefined,
confirm: { askString: async () => "" },
} as unknown as SettingServiceDependencies["APIService"],
});
service.settings = structuredClone(current);
const incoming: Partial<ObsidianLiveSyncSettings> = {
P2P_roomID: "imported-room",
P2P_relays: "wss://imported-relay.example.test",
P2P_passphrase: "imported-passphrase",
};
const merged = { ...structuredClone(DEFAULT_SETTINGS), ...incoming };
preserveManagedTurnProfilesOnMarkdownImport(incoming, current, merged);
await service.applyExternalSettings(merged, true);
const saved = service.saved!.remoteConfigurations.managed;
const uri = saved.isEncrypted ? await service.decryptConfigurationItem(saved.uri, "*") : saved.uri;
expect(uri).toBe(originalURI);
expect(service.settings.P2P_roomID).toBe("local-room");
await service.loadSettings();
expect(service.settings.P2P_roomID).toBe("local-room");
});
it("redacts provider fields and issued credentials, including unknown integrations", () => {
const settings = configuredSettings();
settings.P2P_iceServerSource.id = "private-token";
redactTurnSourceForReport(settings);
expect(JSON.stringify(settings.P2P_iceServerSource)).not.toMatch(/private-token|private-key-id/);
expect(settings.P2P_iceServerSource.configuration).toEqual({ redacted: true });
settings.P2P_managedType = "private-token";
redactTurnSettingsForReport(settings);
expect([settings.P2P_managedType, settings.P2P_managedId, settings.P2P_managedToken]).toEqual([
"redacted",
"redacted",
"redacted",
]);
});
it("omits the whole managed profile group from Markdown, including inactive sources", () => {
const settings = configuredSettings();
settings.P2P_iceServerSource.id = "manual";
settings.P2P_managedType = "";
expect(hasManagedTurnSettings(settings)).toBe(true);
omitManagedTurnProfilesFromMarkdown(settings);
expect(JSON.stringify(settings)).not.toMatch(/private-token|private-key-id|sls\+p2p/);
@@ -52,12 +120,12 @@ describe("managed TURN settings privacy", () => {
const current = configuredSettings();
const incoming = { ...DEFAULT_SETTINGS };
delete (incoming as Partial<typeof incoming>).remoteConfigurations;
delete (incoming as Partial<typeof incoming>).P2P_iceServerSource;
delete (incoming as Partial<typeof incoming>).P2P_managedType;
const merged = { ...DEFAULT_SETTINGS, ...incoming };
preserveManagedTurnProfilesOnMarkdownImport(incoming, current, merged);
expect(merged.remoteConfigurations).toEqual(current.remoteConfigurations);
expect(merged.remoteConfigurations).not.toBe(current.remoteConfigurations);
expect(merged.P2P_iceServerSource).toEqual(current.P2P_iceServerSource);
expect(merged.P2P_managedToken).toEqual(current.P2P_managedToken);
expect(merged.activeConfigurationId).toBe("central");
expect(merged.P2P_ActiveRemoteConfigurationId).toBe("managed");
});
+26 -38
View File
@@ -1,47 +1,33 @@
<script lang="ts">
import type { P2PConnectionInfo } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { iceServerSourceDefinitions, validateIceServerSourceConfiguration } from "@/integrations/iceServerSources";
import { CLOUDFLARE_TURN_TYPE } from "@/integrations/cloudflare/settings";
import { validateManagedTurnSettings } from "@/integrations/turnSettings";
import { translateLiveSyncMessage as translate, translateIfAvailable } from "@/common/translation";
type TurnSettings = Pick<P2PConnectionInfo, "P2P_turnServers" | "P2P_turnUsername" | "P2P_turnCredential" | "P2P_iceServerSource">;
type TurnSettings = Pick<P2PConnectionInfo, "P2P_turnServers" | "P2P_turnUsername" | "P2P_turnCredential" | "P2P_managedType" | "P2P_managedId" | "P2P_managedToken">;
let { settings = $bindable() }: { settings: TurnSettings } = $props();
const sourceId = $derived(settings.P2P_iceServerSource?.id ?? "manual");
const definition = $derived(iceServerSourceDefinitions.find((source) => source.id === sourceId));
const error = $derived(validateIceServerSourceConfiguration(settings.P2P_iceServerSource));
const managedType = $derived(settings.P2P_managedType ?? "");
const error = $derived(validateManagedTurnSettings(settings));
function selectSource(id: string) {
const selected = iceServerSourceDefinitions.find((source) => source.id === id);
settings.P2P_iceServerSource = selected
? { version: 1, id, configuration: Object.fromEntries(selected.fields.map((field) => [field.key, ""])) }
: undefined;
}
function fieldValue(key: string): string {
const value = settings.P2P_iceServerSource?.configuration?.[key];
return typeof value === "string" ? value : "";
}
function setField(key: string, value: string) {
const source = settings.P2P_iceServerSource;
if (!source) return;
settings.P2P_iceServerSource = { ...source, configuration: { ...source.configuration, [key]: value } };
function selectProvider(type: string) {
settings.P2P_managedType = type || undefined;
settings.P2P_managedId = type ? "" : undefined;
settings.P2P_managedToken = type ? "" : undefined;
}
</script>
<div class="turn-configuration">
<label>
<span>{translate("TURN configuration")}</span>
<select aria-label={translate("TURN configuration")} name="p2p-turn-source" value={sourceId} onchange={(event) => selectSource(event.currentTarget.value)}>
<option value="manual">{translate("Manual")}</option>
{#each iceServerSourceDefinitions as source (source.id)}
<option value={source.id}>{translate(source.label)}</option>
{/each}
{#if sourceId !== "manual" && !definition}
<option value={sourceId} disabled>{translate("Unsupported TURN configuration")}</option>
<select aria-label={translate("TURN configuration")} name="p2p-turn-source" value={managedType} onchange={(event) => selectProvider(event.currentTarget.value)}>
<option value="">{translate("Manual")}</option>
<option value={CLOUDFLARE_TURN_TYPE}>{translate("Cloudflare")}</option>
{#if managedType !== "" && managedType !== CLOUDFLARE_TURN_TYPE}
<option value={managedType} disabled>{translate("Unsupported TURN configuration")}</option>
{/if}
</select>
</label>
{#if sourceId === "manual"}
{#if managedType === ""}
<label>
<span>{translate("TURN Server URLs (comma-separated)")}</span>
<textarea name="p2p-turn-servers" rows="3" placeholder="turn:turn.example.com:3478"
@@ -57,15 +43,17 @@
<input type="password" name="p2p-turn-credential" placeholder={translate("Enter TURN credential")} bind:value={settings.P2P_turnCredential}
autocomplete="new-password" />
</label>
{:else if definition}
{#each definition.fields as field (field.key)}
<label>
<span>{translate(field.label)}</span>
<input type={field.secret ? "password" : "text"} name={`p2p-turn-${field.key}`}
value={fieldValue(field.key)} oninput={(event) => setField(field.key, event.currentTarget.value)}
autocomplete={field.secret ? "new-password" : "off"} autocapitalize="off" spellcheck="false" />
</label>
{/each}
{:else if managedType === CLOUDFLARE_TURN_TYPE}
<label>
<span>{translate("TURN Key ID")}</span>
<input type="text" name="p2p-turn-turnKeyId" bind:value={settings.P2P_managedId}
autocomplete="off" autocapitalize="off" spellcheck="false" />
</label>
<label>
<span>{translate("TURN Key API Token")}</span>
<input type="password" name="p2p-turn-apiToken" bind:value={settings.P2P_managedToken}
autocomplete="new-password" autocapitalize="off" spellcheck="false" />
</label>
<p>{translate("The API token is saved with this profile and included in Setup URI and QR code sharing. Temporary TURN credentials are kept in memory only.")}</p>
{/if}
{#if error}
+6 -44
View File
@@ -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,
};
}
@@ -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();
}
}
@@ -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"
);
});
});
-74
View File
@@ -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"
);
});
});
+14
View File
@@ -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 ?? "",
});
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { useIceServerSources } from "@/serviceFeatures/useIceServerSources";
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
import { getLanguage, Notice, Plugin, type App, type PluginManifest } from "./deps";
import { setGetLanguage } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
setGetLanguage(getLanguage);
@@ -184,7 +184,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
core,
(_compatibilityReplicator, p2p) => createInteractiveP2PReplication(p2p),
createOpenRebuildUI(this.app),
{ iceServerSources: useIceServerSources(core.services.API.webCompatFetch.bind(core.services.API)) }
{ prepareP2PSettings: useP2PSettingsPreparation(core.services.API.webCompatFetch.bind(core.services.API)) }
);
setupManager.registerP2PSetupConnectionProbe(replicator.connectionProbe);
useP2PReplicatorCommands(core, replicator);
@@ -1,6 +1,6 @@
<script lang="ts">
import TurnConfiguration from "@/features/P2PSync/TurnConfiguration.svelte";
import { validateIceServerSourceConfiguration } from "@/integrations/iceServerSources";
import { validateManagedTurnSettings } from "@/integrations/turnSettings";
// import { delay } from "octagonal-wheels/promises";
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
@@ -101,14 +101,14 @@
async function checkConnection() {
try {
processing = true;
const sourceError = validateIceServerSourceConfiguration(syncSetting.P2P_iceServerSource);
const sourceError = validateManagedTurnSettings(syncSetting);
if (sourceError) return sourceError;
const trialRemoteSetting = generateSetting();
const admission = connectionProbe;
if (!admission) {
throw new Error("The P2P Setup connection probe is not available.");
}
const result = await coordinateP2PSetupConnectionProbe(admission, trialRemoteSetting, async () => {
const result = await coordinateP2PSetupConnectionProbe(admission, trialRemoteSetting, async (signallingSettings) => {
const map = new Map<string, unknown>();
const store = {
get: (key: string) => {
@@ -136,7 +136,7 @@
const env: ReplicatorHostEnv = {
events: context.context.events,
translate: context.context.translate,
settings: trialRemoteSetting,
settings: signallingSettings,
processReplicatedDocs: async (_docs: PouchDB.Core.ExistingDocument<EntryDoc>[]) => {
return;
},
@@ -207,7 +207,7 @@
}
}
function commit() {
error = validateIceServerSourceConfiguration(syncSetting.P2P_iceServerSource) ?? "";
error = validateManagedTurnSettings(syncSetting) ?? "";
if (error) return;
const setting = pickP2PSyncSettings(generateSetting());
setResult(setting);
@@ -221,7 +221,7 @@
syncSetting.P2P_roomID.trim() !== "" &&
syncSetting.P2P_passphrase.trim() !== "" &&
(syncSetting.P2P_DevicePeerName ?? "").trim() !== "" &&
validateIceServerSourceConfiguration(syncSetting.P2P_iceServerSource) === undefined
validateManagedTurnSettings(syncSetting) === undefined
);
});
</script>
@@ -3,6 +3,7 @@ import {
type P2PConnectionProbeAdmission,
type P2PConnectionProbeSettings,
} from "@vrtmrz/livesync-commonlib/p2p";
import { P2PConnectionPaths, type P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
export type P2PSetupConnectionProbeResult =
| { readonly ok: true }
@@ -20,12 +21,25 @@ export interface P2PSetupConnectionProbe {
}
/** Interpret the stable P2P owner's admission without constructing transport eagerly. */
export async function coordinateP2PSetupConnectionProbe(
export async function coordinateP2PSetupConnectionProbe<T extends P2PConnectionProbeSettings>(
admission: P2PConnectionProbeAdmission,
trialSettings: P2PConnectionProbeSettings,
runOwnedTrial: () => Promise<P2PSetupConnectionProbeResult>
trialSettings: T,
runOwnedTrial: (settings: T) => Promise<P2PSetupConnectionProbeResult>
): Promise<P2PSetupConnectionProbeResult> {
const settlement = await admission.run(trialSettings, runOwnedTrial);
const settlement = await admission.run(trialSettings, () => {
// This trial checks signalling only; TURN allocation belongs to an actual connection.
const settings: T & Partial<P2PSyncSetting> = { ...trialSettings };
delete settings.P2P_managedType;
delete settings.P2P_managedId;
delete settings.P2P_managedToken;
delete settings.P2P_iceServers;
delete settings.P2P_iceServersExpiresAt;
settings.P2P_turnServers = "";
settings.P2P_turnUsername = "";
settings.P2P_turnCredential = "";
settings.P2P_connectionPath = P2PConnectionPaths.Automatic;
return runOwnedTrial(settings);
});
if (settlement.status === "observed-active") return { ok: true };
if (settlement.status === "blocked") {
return {
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { ACTIVE_P2P_RELAY_BINDING_CONFLICT, type P2PConnectionProbeAdmission } from "@vrtmrz/livesync-commonlib/p2p";
import { DEFAULT_SETTINGS, P2PConnectionPaths } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
coordinateP2PSetupConnectionProbe,
probeP2PSetupConnection,
@@ -7,6 +8,40 @@ import {
} from "./p2pSetupConnectionProbe";
describe("P2P setup connection probe", () => {
it("constructs a signalling-only trial when the draft selects managed TURN", async () => {
const settings = {
...DEFAULT_SETTINGS,
P2P_managedType: "CF",
P2P_managedId: "test-key",
P2P_managedToken: "test-token",
P2P_iceServers: [
{ urls: "turn:temporary.example.test", username: "issued-user", credential: "issued-password" },
],
P2P_iceServersExpiresAt: 123456789,
P2P_turnServers: "turn:unused.example.test:3478",
P2P_turnUsername: "unused-user",
P2P_turnCredential: "unused-password",
P2P_connectionPath: P2PConnectionPaths.Relay,
};
const admission: P2PConnectionProbeAdmission = {
run: async (_settings, trial) => ({ status: "trial", result: await trial() }),
};
const result = await coordinateP2PSetupConnectionProbe(admission, settings, async (trial = settings) => {
expect(trial.P2P_managedType).toBeUndefined();
expect(trial.P2P_managedToken).toBeUndefined();
expect(trial.P2P_iceServers).toBeUndefined();
expect(trial.P2P_iceServersExpiresAt).toBeUndefined();
expect(trial.P2P_turnServers).toBe("");
expect(trial.P2P_turnUsername).toBe("");
expect(trial.P2P_turnCredential).toBe("");
expect(trial.P2P_connectionPath).toBe(P2PConnectionPaths.Automatic);
return { ok: true };
});
expect(result).toEqual({ ok: true });
expect(settings.P2P_managedToken).toBe("test-token");
expect(settings.P2P_connectionPath).toBe(P2PConnectionPaths.Relay);
});
it("uses a compatible active signalling connection without constructing a trial", async () => {
const runOwnedTrial = vi.fn(async (): Promise<P2PSetupConnectionProbeResult> => ({ ok: true }));
const admission: P2PConnectionProbeAdmission = {
@@ -19,15 +19,9 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/API/processSetting", () => {
describe("setupObsidian/qrCode", () => {
it("shows managed TURN settings and inactive profiles through the ordinary QR dialogue", async () => {
const source = {
version: 1,
id: "cloudflare",
configuration: { turnKeyId: "turn-key", apiToken: "private-token" },
};
const settings = {
P2P_iceServerSource: source,
remoteConfigurations: {
managed: { uri: `sls+p2p://room?source=${encodeURIComponent(JSON.stringify(source))}` },
managed: { uri: "sls+p2p://room?managedType=CF&managedId=turn-key&token=private-token" },
},
};
const confirmWithMessage = vi.fn();
@@ -1,17 +0,0 @@
import { CLOUDFLARE_ICE_SERVER_SOURCE_ID } from "@/integrations/cloudflare/settings";
import type { IceServerSourceFactoryCatalogue } from "@vrtmrz/livesync-commonlib/p2p";
import {
createCloudflareIceServerSource,
type CloudflareIceServerSourceFetch,
} from "@/integrations/cloudflare/iceServerSource";
/**
* Compose the closed LiveSync-owned ICE source catalogue from a host HTTP
* adapter. The adapter is intentionally narrow so this feature does not take
* a dependency on LiveSync core or on native request APIs.
*/
export function useIceServerSources(fetch: CloudflareIceServerSourceFetch): IceServerSourceFactoryCatalogue {
return {
[CLOUDFLARE_ICE_SERVER_SOURCE_ID]: (configuration) => createCloudflareIceServerSource(configuration, { fetch }),
};
}
@@ -0,0 +1,18 @@
import type { P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { acquireCloudflareTurnCredentials, type CloudflareTurnFetch } from "@/integrations/cloudflare/turnCredentials";
import { validateManagedTurnSettings } from "@/integrations/turnSettings";
/** Prepare a connection copy using the host's HTTP adapter. */
export function useP2PSettingsPreparation(fetch: CloudflareTurnFetch) {
return async (settings: Readonly<P2PSyncSetting>, signal: AbortSignal): Promise<P2PSyncSetting> => {
const error = validateManagedTurnSettings(settings);
if (error) throw new Error(error);
if (!settings.P2P_managedType) return { ...settings };
const { iceServers, expiresAt } = await acquireCloudflareTurnCredentials(
{ turnKeyId: settings.P2P_managedId ?? "", apiToken: settings.P2P_managedToken ?? "" },
{ fetch },
signal
);
return { ...settings, P2P_iceServers: iceServers, P2P_iceServersExpiresAt: expiresAt };
};
}
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { useP2PSettingsPreparation } from "./useP2PSettingsPreparation";
const managed = {
...DEFAULT_SETTINGS,
P2P_managedType: "CF",
P2P_managedId: "key-123",
P2P_managedToken: "test-token",
};
describe("host preparation of P2P settings", () => {
it("puts issued ICE credentials on a connection copy without changing saved inputs", async () => {
const iceServers = [
{ urls: ["turn:relay.example.test:3478"], username: "issued-user", credential: "issued-password" },
];
const fetch = vi.fn(async () => new Response(JSON.stringify({ iceServers }), { status: 201 }));
const before = structuredClone(managed);
const settings = await useP2PSettingsPreparation(fetch)(managed, new AbortController().signal);
expect(settings.P2P_iceServers).toEqual(iceServers);
expect(settings.P2P_iceServersExpiresAt).toBeGreaterThan(Date.now());
expect(managed).toEqual(before);
expect(settings).not.toBe(managed);
expect(fetch).toHaveBeenCalledOnce();
});
it("keeps manual settings and rejects an unsupported provider without HTTP requests", async () => {
const fetch = vi.fn();
const prepare = useP2PSettingsPreparation(fetch);
await expect(prepare(DEFAULT_SETTINGS, new AbortController().signal)).resolves.toEqual(DEFAULT_SETTINGS);
await expect(prepare({ ...managed, P2P_managedType: "unknown" }, new AbortController().signal)).rejects.toThrow(
"not supported"
);
await expect(
prepare({ ...managed, P2P_managedToken: "invalid token" }, new AbortController().signal)
).rejects.toThrow("Bearer token syntax");
expect(fetch).not.toHaveBeenCalled();
});
it("propagates a safe acquisition failure without using the manual TURN fields", async () => {
const fetch = vi.fn(async () => new Response(null, { status: 401 }));
const prepare = useP2PSettingsPreparation(fetch);
await expect(
prepare({ ...managed, P2P_turnServers: "turn:manual.example.test" }, new AbortController().signal)
).rejects.toThrow("not authorised");
expect(fetch).toHaveBeenCalledOnce();
});
});