mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-21 18:17:05 +00:00
Use host preparation for TURN connection settings
This commit is contained in:
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.":
|
||||
|
||||
@@ -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:")
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ?? "",
|
||||
});
|
||||
}
|
||||
+2
-2
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user