Add optional Cloudflare TURN credentials and secure profile sharing

This commit is contained in:
vorotamoroz
2026-09-15 16:20:03 +00:00
parent ba297d1233
commit 93bc161f20
40 changed files with 1855 additions and 182 deletions
@@ -1,105 +1,61 @@
<script lang="ts">
import { onMount } from "svelte";
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
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 { validateTurnSettings } from "@/integrations/iceServerSources";
interface Props {
host: P2PReplicatorPaneHost;
}
let { host }: Props = $props();
let { host }: { host: P2PReplicatorPaneHost } = $props();
const currentSettings = () => host.services.setting.currentSettings() as P2PSyncSetting;
const initialSettings = currentSettings();
let savedTurnServers = $state(initialSettings.P2P_turnServers);
let savedTurnUsername = $state(initialSettings.P2P_turnUsername);
let savedTurnCredential = $state(initialSettings.P2P_turnCredential);
let turnServers = $state(initialSettings.P2P_turnServers);
let turnUsername = $state(initialSettings.P2P_turnUsername);
let turnCredential = $state(initialSettings.P2P_turnCredential);
const isTurnServersModified = $derived(turnServers !== savedTurnServers);
const isTurnUsernameModified = $derived(turnUsername !== savedTurnUsername);
const isTurnCredentialModified = $derived(turnCredential !== savedTurnCredential);
const isModified = $derived(
isTurnServersModified || isTurnUsernameModified || isTurnCredentialModified
);
function turnSettings(settings: P2PSyncSetting) {
return {
P2P_turnServers: settings.P2P_turnServers,
P2P_turnUsername: settings.P2P_turnUsername,
P2P_turnCredential: settings.P2P_turnCredential,
P2P_iceServerSource: structuredClone(settings.P2P_iceServerSource),
encryptedP2PIceServerSource: settings.encryptedP2PIceServerSource,
};
}
let draft = $state(turnSettings(currentSettings()));
let saved = $state(JSON.stringify(turnSettings(currentSettings())));
const isModified = $derived(JSON.stringify(draft) !== saved);
const sourceError = $derived(validateTurnSettings(draft));
function loadSettings(settings: P2PSyncSetting): void {
savedTurnServers = settings.P2P_turnServers;
savedTurnUsername = settings.P2P_turnUsername;
savedTurnCredential = settings.P2P_turnCredential;
turnServers = savedTurnServers;
turnUsername = savedTurnUsername;
turnCredential = savedTurnCredential;
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", (settings) => loadSettings(settings as P2PSyncSetting)));
async function save(): Promise<void> {
await host.services.setting.applyPartial(
{
P2P_turnServers: turnServers,
P2P_turnUsername: turnUsername,
P2P_turnCredential: turnCredential,
},
true
);
if (sourceError) return;
const values = $state.snapshot(draft);
await host.services.setting.updateSettings((settings) => {
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 });
}
return next;
}, true);
loadSettings(currentSettings());
}
function revert(): void {
turnServers = savedTurnServers;
turnUsername = savedTurnUsername;
turnCredential = savedTurnCredential;
}
</script>
<section class="browser-p2p-transport-settings">
<details>
<summary>Optional TURN server settings</summary>
<p>
Configure TURN only when a direct peer-to-peer connection cannot be established.
</p>
<label class:is-dirty={isTurnServersModified}>
<span>TURN Server URLs (comma-separated)</span>
<input
type="text"
placeholder="turn:turn.example.com:3478"
bind:value={turnServers}
autocomplete="off"
spellcheck="false"
autocorrect="off"
/>
</label>
<label class:is-dirty={isTurnUsernameModified}>
<span>TURN Username</span>
<input
type="text"
placeholder="Enter TURN username"
bind:value={turnUsername}
autocomplete="off"
/>
</label>
<label class:is-dirty={isTurnCredentialModified}>
<span>TURN Credential</span>
<input
type="password"
placeholder="Enter TURN credential"
bind:value={turnCredential}
autocomplete="new-password"
/>
</label>
<p>Configure TURN only when a direct peer-to-peer connection cannot be established.</p>
<TurnConfiguration bind:settings={draft} />
<div class="actions">
<button type="button" class="button mod-cta" disabled={!isModified} onclick={save}>
<button type="button" class="button mod-cta" disabled={!isModified || !!sourceError} onclick={save}>
Save TURN settings
</button>
<button type="button" class="button" disabled={!isModified} onclick={revert}>
<button type="button" class="button" disabled={!isModified} onclick={() => loadSettings(currentSettings())}>
Revert TURN settings
</button>
</div>
@@ -107,27 +63,7 @@
</section>
<style>
.browser-p2p-transport-settings {
margin-bottom: 1rem;
}
p {
margin: 0.75rem 0;
}
label {
display: grid;
gap: 0.25rem;
margin: 0.75rem 0;
}
label.is-dirty {
background-color: var(--background-modifier-error);
}
input {
box-sizing: border-box;
width: 100%;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.browser-p2p-transport-settings { margin-bottom: 1rem; }
p { margin: 0.75rem 0; }
.actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
</style>
+2 -2
View File
@@ -1,5 +1,5 @@
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
import { configURIBase, configURIBaseV2 } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
import {
DEFAULT_SETTINGS,
type FilePathWithPrefix,
@@ -298,7 +298,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
throw new Error("setup requires one argument: <setupURI>");
}
const setupURI = options.commandArgs[0].trim();
if (!setupURI.startsWith(configURIBase)) {
if (!setupURI.startsWith(configURIBase) && !setupURI.startsWith(configURIBaseV2)) {
throw new Error(`setup URI must start with ${configURIBase}`);
}
const passphrase = await standardIo.prompt("Enter setup URI passphrase: ");
+26 -1
View File
@@ -1,7 +1,7 @@
import { fsPromises as fs, os, path } from "@vrtmrz/livesync-commonlib/node";
import * as processSetting from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
import { configURIBase, configURIBaseV2 } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
@@ -419,6 +419,31 @@ describe("runCommand abnormal cases", () => {
expect(appliedSettings.useIndexedDBAdapter).toBe(false);
});
it("setup imports managed TURN through the versioned encrypted URI", async () => {
const core = createCoreMock();
const source = {
version: 1,
id: "cloudflare",
configuration: { turnKeyId: "turn-key", apiToken: "private-token" },
};
const passphrase = "setup-passphrase";
const setupURI = await processSetting.encodeSettingsToSetupURI(
{
...DEFAULT_SETTINGS,
P2P_iceServerSource: source,
},
passphrase
);
expect(setupURI.startsWith(configURIBaseV2)).toBe(true);
expect(setupURI).not.toContain("private-token");
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 }),
true
);
});
it("setup rejects encoded URI when passphrase is wrong", async () => {
const core = createCoreMock();
const setupURI = await createSetupURI("correct-passphrase");
+4 -1
View File
@@ -1,3 +1,4 @@
import { useIceServerSources } from "@/serviceFeatures/useIceServerSources";
import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub";
import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./services/NodeLocalStorage";
import { LiveSyncBaseCore, type StartupDatabaseOptions } from "@/LiveSyncBaseCore";
@@ -524,7 +525,9 @@ export async function main(
useOfflineScanner(core);
}
// Register P2P replicator feature.
p2pReplicator = useP2PReplicatorFeature(core);
p2pReplicator = useP2PReplicatorFeature(core, undefined, undefined, {
iceServerSources: useIceServerSources(core.services.API.webCompatFetch.bind(core.services.API)),
});
// Add target filter to prevent internal files are handled
core.services.vault.isTargetFile.addHandler(async (target) => {
const targetPath = stripAllPrefixes(getPathFromUXFileInfo(target));
+4 -1
View File
@@ -1,3 +1,4 @@
import { useIceServerSources } from "@/serviceFeatures/useIceServerSources";
/** Browser runtime for Self-hosted LiveSync over the File System Access API. */
import { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
@@ -217,7 +218,9 @@ export class WebAppRuntime {
useRedFlagFeatures(core);
useCheckRemoteSize(core);
useRemoteConfiguration(core);
this.p2p = useP2PReplicatorFeature(core);
this.p2p = useP2PReplicatorFeature(core, undefined, undefined, {
iceServerSources: useIceServerSources(core.services.API.webCompatFetch.bind(core.services.API)),
});
this.paneHost = {
services: core.services,
p2p: this.p2p,
+3 -3
View File
@@ -1,3 +1,4 @@
import { useIceServerSources } from "@/serviceFeatures/useIceServerSources";
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";
@@ -70,9 +71,8 @@ export class WebPeerRuntime {
isScheduled: () => this.restartScheduled,
},
});
this.p2p = useP2PReplicatorFeature({
services: this.services,
serviceModules: {},
this.p2p = useP2PReplicatorFeature({ services: this.services, serviceModules: {} }, undefined, undefined, {
iceServerSources: useIceServerSources(this.services.API.webCompatFetch.bind(this.services.API)),
});
this.p2pLogCollector = new P2PLogCollector(this.events);
this.paneHost = {
@@ -7,6 +7,33 @@
* remove it from this map in the same change.
*/
export const liveSyncProvisionalEnglishMessages = {
"Configure TURN when a direct connection cannot be established or when you select TURN relay only.":
"Configure TURN when a direct connection cannot be established or when you select TURN relay only.",
"TURN configuration could not be decrypted.": "TURN configuration could not be decrypted.",
"TURN configuration": "TURN configuration",
Manual: "Manual",
Cloudflare: "Cloudflare",
"TURN Key ID": "TURN Key ID",
"TURN Key API Token": "TURN Key API Token",
"Unsupported TURN configuration": "Unsupported TURN configuration",
"The API token is saved with this profile and included in encrypted Setup URI sharing. Temporary TURN credentials are kept in memory only.":
"The API token is saved with this profile and included in encrypted Setup URI sharing. Temporary TURN credentials are kept in memory only.",
"TURN relay only requires a TURN server or a configured credential source under Advanced Settings.":
"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.",
"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.":
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.",
@@ -28,8 +55,8 @@ export const liveSyncProvisionalEnglishMessages = {
"The project's public signalling relay is a best-effort convenience operated by the project author. It does not store Vault contents, but signalling metadata may be visible to the relay. Availability and log retention are not guaranteed. You can replace it with your own Nostr-compatible relay.",
"Learn more about P2P connections": "Learn more about P2P connections",
"Learn more about signalling and TURN": "Learn more about signalling and TURN",
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust.":
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust.",
"WebRTC encrypts data between your devices, including when it passes through TURN. The TURN provider cannot read the transferred data. It can see network addresses and traffic volume.":
"WebRTC encrypts data between your devices, including when it passes through TURN. The TURN provider cannot read the transferred data. It can see network addresses and traffic volume.",
"Connection compatibility": "Connection compatibility",
"P2P message size": "P2P message size",
Standard: "Standard",
+2
View File
@@ -1,3 +1,4 @@
import { redactTurnSourceForReport } 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";
@@ -67,6 +68,7 @@ export async function generateReport(settings: ObsidianLiveSyncSettings, core: L
delete pluginConfig[key as keyof ObsidianLiveSyncSettings];
}
redactTurnSourceForReport(pluginConfig);
pluginConfig.couchDB_DBNAME = REDACTED;
pluginConfig.couchDB_PASSWORD = REDACTED;
const scheme = pluginConfig.couchDB_URI.startsWith("http:")
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/settings";
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { generateReport } from "./reportTool";
vi.mock("./utils", () => ({ requestToCouchDBWithCredentials: vi.fn() }));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({
compatGlobal: { origin: "test", navigator: { userAgent: "test" } },
}));
describe("TURN credentials in diagnostic reports", () => {
it("redacts top-level, encrypted, and inactive encoded source copies", async () => {
const token = "private+token/with=symbols";
const source = { version: 1, id: "cloudflare", configuration: { turnKeyId: "private-key", apiToken: token } };
const settings = {
...DEFAULT_SETTINGS,
remoteType: REMOTE_P2P,
P2P_iceServerSource: source,
encryptedP2PIceServerSource: "encrypted-private-copy",
remoteConfigurations: {
inactive: {
id: "inactive",
name: "Inactive TURN",
isEncrypted: false,
uri: `sls+p2p-v2://room?source=${encodeURIComponent(JSON.stringify(source))}`,
},
},
};
const core = { services: { vault: { isStorageInsensitive: () => false } } } as unknown as LiveSyncBaseCore;
const report = await generateReport(settings, core);
const text = JSON.stringify(report);
expect(text).not.toContain(token);
expect(text).not.toContain(encodeURIComponent(token));
expect(text).not.toContain("private-key");
expect(text).not.toContain("encrypted-private-copy");
expect(report.pluginConfig.remoteConfigurations.inactive.uri).toBe("sls+p2p-v2://");
expect(settings.P2P_iceServerSource).toEqual(source);
expect(settings.encryptedP2PIceServerSource).toBe("encrypted-private-copy");
});
});
+52
View File
@@ -0,0 +1,52 @@
import {
hasManagedP2PIceServerSource as hasManagedTurnSettings,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { iceServerSourceDefinitions } from "@/integrations/iceServerSources";
export { hasManagedTurnSettings };
/** Reports retain the selected source label, but no opaque source configuration. */
export function redactTurnSourceForReport(settings: Partial<ObsidianLiveSyncSettings>): void {
if (settings.encryptedP2PIceServerSource) settings.encryptedP2PIceServerSource = "REDACTED";
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 },
};
}
}
/** Managed connection profiles are shared through encrypted Setup URIs. */
export function omitManagedTurnProfilesFromMarkdown(settings: Partial<ObsidianLiveSyncSettings>): void {
if (!hasManagedTurnSettings(settings)) return;
delete settings.P2P_iceServerSource;
delete settings.encryptedP2PIceServerSource;
delete settings.remoteConfigurations;
delete settings.activeConfigurationId;
delete settings.P2P_ActiveRemoteConfigurationId;
}
/** An omitted profile group leaves this device's existing connection selection intact. */
export function preserveManagedTurnProfilesOnMarkdownImport(
incoming: Partial<ObsidianLiveSyncSettings>,
current: ObsidianLiveSyncSettings,
merged: ObsidianLiveSyncSettings
): void {
if (
!hasManagedTurnSettings(current) ||
incoming.remoteConfigurations !== undefined ||
incoming.P2P_iceServerSource !== undefined
) {
return;
}
merged.remoteConfigurations = structuredClone(current.remoteConfigurations);
merged.activeConfigurationId = current.activeConfigurationId;
merged.P2P_ActiveRemoteConfigurationId = current.P2P_ActiveRemoteConfigurationId;
merged.P2P_iceServerSource = structuredClone(current.P2P_iceServerSource);
merged.encryptedP2PIceServerSource = current.encryptedP2PIceServerSource;
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
hasManagedTurnSettings,
omitManagedTurnProfilesFromMarkdown,
preserveManagedTurnProfilesOnMarkdownImport,
redactTurnSourceForReport,
} from "./turnSettingsPrivacy";
function configuredSettings() {
return {
...DEFAULT_SETTINGS,
P2P_iceServerSource: {
version: 1,
id: "cloudflare",
configuration: { turnKeyId: "private-key-id", apiToken: "private-token" },
},
remoteConfigurations: {
managed: {
id: "managed",
name: "Managed TURN",
isEncrypted: false,
uri: "sls+p2p-v2://room?source=private-token",
},
},
activeConfigurationId: "central",
P2P_ActiveRemoteConfigurationId: "managed",
};
}
describe("managed TURN settings privacy", () => {
it("redacts all opaque source fields, 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 });
});
it("omits the whole managed profile group from Markdown, including inactive sources", () => {
const settings = configuredSettings();
settings.P2P_iceServerSource.id = "manual";
expect(hasManagedTurnSettings(settings)).toBe(true);
omitManagedTurnProfilesFromMarkdown(settings);
expect(JSON.stringify(settings)).not.toMatch(/private-token|private-key-id|sls\+p2p-v2/);
expect(settings).not.toHaveProperty("remoteConfigurations");
expect(settings).not.toHaveProperty("activeConfigurationId");
expect(settings).not.toHaveProperty("P2P_ActiveRemoteConfigurationId");
});
it("preserves existing profiles and both selections when Markdown omits the group", () => {
const current = configuredSettings();
const incoming = { ...DEFAULT_SETTINGS };
delete (incoming as Partial<typeof incoming>).remoteConfigurations;
delete (incoming as Partial<typeof incoming>).P2P_iceServerSource;
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.activeConfigurationId).toBe("central");
expect(merged.P2P_ActiveRemoteConfigurationId).toBe("managed");
});
it("retains the manual-only Markdown contract", () => {
const settings = { ...DEFAULT_SETTINGS };
const before = structuredClone(settings);
omitManagedTurnProfilesFromMarkdown(settings);
expect(settings).toEqual(before);
});
});
+1 -1
View File
@@ -51,7 +51,7 @@ export type queueItem = {
export const FileWatchEventQueueMax = 10;
export { configURIBase, configURIBaseQR } from "@vrtmrz/livesync-commonlib/compat/common/types";
export { configURIBase, configURIBaseV2, configURIBaseQR } from "@vrtmrz/livesync-commonlib/compat/common/types";
export {
CHeader,
@@ -0,0 +1,83 @@
<script lang="ts">
import type { P2PConnectionInfo } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { iceServerSourceDefinitions, validateTurnSettings } from "@/integrations/iceServerSources";
import { translateLiveSyncMessage as translate, translateIfAvailable } from "@/common/translation";
type TurnSettings = Pick<P2PConnectionInfo,
"P2P_turnServers" | "P2P_turnUsername" | "P2P_turnCredential" | "P2P_iceServerSource" | "encryptedP2PIceServerSource">;
let { settings = $bindable() }: { settings: TurnSettings } = $props();
const sourceId = $derived(settings.P2P_iceServerSource?.id ?? (settings.encryptedP2PIceServerSource ? "unavailable" : "manual"));
const definition = $derived(iceServerSourceDefinitions.find((source) => source.id === sourceId));
const error = $derived(validateTurnSettings(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;
settings.encryptedP2PIceServerSource = "";
}
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 } };
}
</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>
{/if}
</select>
</label>
{#if sourceId === "manual"}
<label>
<span>{translate("TURN Server URLs (comma-separated)")}</span>
<textarea name="p2p-turn-servers" rows="3" placeholder="turn:turn.example.com:3478"
bind:value={settings.P2P_turnServers} autocapitalize="off" spellcheck="false"></textarea>
</label>
<label>
<span>{translate("TURN Username")}</span>
<input type="text" name="p2p-turn-username" placeholder={translate("Enter TURN username")} bind:value={settings.P2P_turnUsername}
autocomplete="off" autocapitalize="off" spellcheck="false" />
</label>
<label>
<span>{translate("TURN Credential")}</span>
<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}
<p>{translate("The API token is saved with this profile and included in encrypted Setup URI sharing. Temporary TURN credentials are kept in memory only.")}</p>
{/if}
{#if error}
<p role="status" class="turn-error">{translateIfAvailable(error)}</p>
{/if}
</div>
<style>
label { display: grid; gap: 0.25rem; margin: 0.75rem 0; }
input, textarea, select { box-sizing: border-box; width: 100%; }
p { font-size: var(--font-ui-small, 0.9rem); }
.turn-error { color: var(--text-error, #b33); }
</style>
@@ -0,0 +1,384 @@
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,
} from "./settings";
/** Fetch-compatible function supplied by the host composition. */
export type CloudflareIceServerSourceFetch = (input: string | Request, init?: RequestInit) => Promise<Response>;
export interface CloudflareIceServerSourceDependencies {
readonly fetch: CloudflareIceServerSourceFetch;
readonly now?: () => number;
readonly requestDeadlineMs?: number;
}
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;
type IceServerSourceFailureCode = "configuration" | "authentication" | "unavailable" | "invalid-response";
const SOURCE_FAILURE_MESSAGES: Record<IceServerSourceFailureCode, string> = {
configuration: "The Cloudflare TURN source 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 isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function abortError(): Error {
try {
return new DOMException("The operation was aborted.", "AbortError");
} catch {
const error = new Error("The operation was aborted.");
error.name = "AbortError";
return error;
}
}
function throwIfAborted(signal: AbortSignal): void {
if (signal.aborted) {
throw abortError();
}
}
function isControlCharacter(value: string): boolean {
return Array.from(value).some((character) => {
const code = character.charCodeAt(0);
return code <= 0x1f || code === 0x7f;
});
}
function isPort(value: string): boolean {
if (!/^\d{1,5}$/.test(value)) return false;
const port = Number(value);
return port >= 1 && port <= 65_535;
}
function isHost(value: string): boolean {
return value.length > 0 && /^[A-Za-z0-9._-]+$/.test(value);
}
/**
* Validates the URL forms accepted by WebRTC's ICE server configuration.
* TURN URLs may carry only the standard transport query parameter; userinfo,
* paths, fragments, and arbitrary query values are not accepted.
*/
export function isSupportedIceServerUrl(value: string): boolean {
if (value.length === 0 || value.length > 2_048 || isControlCharacter(value)) return false;
const schemeMatch = /^(stun|stuns|turn|turns):(.+)$/i.exec(value);
if (!schemeMatch) return false;
const remainder = schemeMatch[2];
const queryIndex = remainder.indexOf("?");
const authority = queryIndex >= 0 ? remainder.slice(0, queryIndex) : remainder;
const query = queryIndex >= 0 ? remainder.slice(queryIndex + 1) : "";
if (authority.length === 0 || authority.includes("/") || authority.includes("#") || authority.includes("@")) {
return false;
}
if (authority.includes("%")) return false;
if (authority.startsWith("[")) {
const closingBracket = authority.indexOf("]");
if (closingBracket < 0) return false;
const host = authority.slice(1, closingBracket);
if (!/^[0-9A-Fa-f:.]+$/.test(host) || !host.includes(":")) return false;
const suffix = authority.slice(closingBracket + 1);
if (suffix !== "" && (!suffix.startsWith(":") || !isPort(suffix.slice(1)))) return false;
} else {
const colonIndex = authority.lastIndexOf(":");
const host = colonIndex >= 0 ? authority.slice(0, colonIndex) : authority;
if (!isHost(host) || (colonIndex >= 0 && !isPort(authority.slice(colonIndex + 1)))) return false;
// IPv6 literals must use brackets so a colon cannot be interpreted as
// an ambiguous port separator.
if (colonIndex >= 0 && host.includes(":")) return false;
}
if (query.length === 0) return true;
const queryParts = query.split("&");
return queryParts.length === 1 && /^transport=(udp|tcp)$/i.test(queryParts[0]);
}
function isTurnUrl(value: string): boolean {
return /^(turn|turns):/i.test(value);
}
function isCredential(value: unknown): value is string {
return typeof value === "string" && value.length > 0 && value.length <= 4_096 && !isControlCharacter(value);
}
function normaliseIceServers(value: unknown): readonly RTCIceServer[] {
if (!isRecord(value) || !Array.isArray(value.iceServers)) {
throw sourceFailure("invalid-response", false);
}
if (value.iceServers.length === 0 || value.iceServers.length > CLOUDFLARE_TURN_MAX_ICE_SERVER_ENTRIES) {
throw sourceFailure("invalid-response", false);
}
const servers: RTCIceServer[] = [];
let urlCount = 0;
let hasTurnServer = false;
for (const candidate of value.iceServers) {
if (!isRecord(candidate)) throw sourceFailure("invalid-response", false);
const rawUrls = candidate.urls;
const urls =
typeof rawUrls === "string"
? [rawUrls]
: Array.isArray(rawUrls) && rawUrls.every((url): url is string => typeof url === "string")
? [...rawUrls]
: undefined;
if (!urls || urls.length === 0) throw sourceFailure("invalid-response", false);
urlCount += urls.length;
if (urlCount > CLOUDFLARE_TURN_MAX_ICE_SERVER_URLS || urls.some((url) => !isSupportedIceServerUrl(url))) {
throw sourceFailure("invalid-response", false);
}
const turnEntry = urls.some(isTurnUrl);
hasTurnServer ||= turnEntry;
const normalised: RTCIceServer = { urls };
if (turnEntry) {
if (!isCredential(candidate.username) || !isCredential(candidate.credential)) {
throw sourceFailure("invalid-response", false);
}
normalised.username = candidate.username;
normalised.credential = candidate.credential;
}
servers.push(normalised);
}
if (!hasTurnServer) throw sourceFailure("invalid-response", false);
return Object.freeze(servers);
}
class BoundedResponseError extends Error {
constructor(readonly kind: "too-large" | "invalid-length" | "read-failed") {
super(kind);
}
}
async function readResponseBody(response: Response): Promise<string> {
const contentLength = response.headers.get("content-length");
if (contentLength !== null) {
const declaredLength = Number(contentLength);
if (!Number.isFinite(declaredLength) || declaredLength < 0) {
throw new BoundedResponseError("invalid-length");
}
if (declaredLength > CLOUDFLARE_TURN_MAX_RESPONSE_BYTES) {
throw new BoundedResponseError("too-large");
}
}
if (!response.body) {
try {
const text = await response.text();
if (new TextEncoder().encode(text).byteLength > CLOUDFLARE_TURN_MAX_RESPONSE_BYTES) {
throw new BoundedResponseError("too-large");
}
return text;
} catch (error) {
if (error instanceof BoundedResponseError) throw error;
throw new BoundedResponseError("read-failed");
}
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
while (true) {
const result = await reader.read();
if (result.done) break;
totalBytes += result.value.byteLength;
if (totalBytes > CLOUDFLARE_TURN_MAX_RESPONSE_BYTES) {
try {
await reader.cancel();
} catch {
// The response is already invalid because it exceeded the
// bound; cancellation failure must not change the safe
// classification or expose a host-specific error.
}
throw new BoundedResponseError("too-large");
}
chunks.push(result.value);
}
} catch (error) {
if (error instanceof BoundedResponseError) throw error;
throw new BoundedResponseError("read-failed");
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(bytes);
}
function classifyHttpFailure(status: number): IceServerSourceError {
if (status === 401 || status === 403) {
return sourceFailure("authentication", false);
}
if (status === 408 || status === 429 || status >= 500) {
return sourceFailure("unavailable", true);
}
return sourceFailure("unavailable", false);
}
function parseResponseBody(body: string): readonly RTCIceServer[] {
let value: unknown;
try {
value = JSON.parse(body) as unknown;
} catch {
throw sourceFailure("invalid-response", false);
}
return normaliseIceServers(value);
}
function createSource(
configuration: CloudflareIceServerSourceConfiguration,
dependencies: CloudflareIceServerSourceDependencies
): IceServerSource {
const now = dependencies.now ?? Date.now;
const requestDeadlineMs = dependencies.requestDeadlineMs ?? CLOUDFLARE_TURN_REQUEST_DEADLINE_MS;
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();
}
},
};
}
/**
* 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);
}
/** Exposes the provider validation for the integration catalogue and UI. */
export { validateCloudflareIceServerSourceConfiguration };
@@ -0,0 +1,164 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
CLOUDFLARE_TURN_MAX_RESPONSE_BYTES,
CLOUDFLARE_TURN_REQUEST_DEADLINE_MS,
createCloudflareIceServerSource,
} from "./iceServerSource";
import {
CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT,
CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS,
validateCloudflareIceServerSourceConfiguration,
} from "./settings";
const configuration = {
turnKeyId: "key-123",
apiToken: "token_abc-123",
} as const;
function response(body: unknown, status = 201): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function validBody() {
return {
iceServers: [
{
urls: ["turn:relay.example.test:3478?transport=udp", "turns:relay.example.test:5349"],
username: "turn-user",
credential: "turn-password",
},
{ urls: "stun:stun.example.test:3478" },
],
};
}
afterEach(() => {
vi.useRealTimers();
});
describe("Cloudflare ICE server source", () => {
it("requests the fixed endpoint with the bearer token and TTL", async () => {
const now = 1_000_000;
let requestUrl: string | Request | undefined;
let requestInit: RequestInit | undefined;
const fetch = vi.fn(async (input: string | Request, init?: RequestInit) => {
requestUrl = input;
requestInit = init;
return response(validBody());
});
const source = createCloudflareIceServerSource(configuration, { fetch, now: () => now });
const result = await source.acquire(new AbortController().signal);
expect(requestUrl).toBe(`${CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT}/key-123/credentials/generate-ice-servers`);
expect(requestInit).toMatchObject({
method: "POST",
redirect: "error",
credentials: "omit",
cache: "no-store",
body: JSON.stringify({ ttl: CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS }),
});
expect(new Headers(requestInit?.headers).get("authorization")).toBe("Bearer token_abc-123");
expect(new Headers(requestInit?.headers).get("content-type")).toBe("application/json");
expect(requestInit?.signal).toBeInstanceOf(AbortSignal);
expect(result.iceServers).toHaveLength(2);
expect(result.expiresAt).toBe(now + CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS * 1_000);
});
it("rejects malformed, oversized, and STUN-only responses without exposing secrets", async () => {
const cases: Array<{ body: unknown; expectedCode: string }> = [
{ body: { iceServers: [] }, expectedCode: "invalid-response" },
{ body: { iceServers: [{ urls: "turn:relay.example.test:3478" }] }, expectedCode: "invalid-response" },
{ body: { iceServers: [{ urls: "stun:stun.example.test:3478" }] }, expectedCode: "invalid-response" },
];
for (const testCase of cases) {
const source = createCloudflareIceServerSource(configuration, {
fetch: vi.fn(async () => response(testCase.body)),
now: () => 1_000_000,
});
const error = await source.acquire(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, {
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);
expect(error).toMatchObject({ code: "invalid-response" });
});
it("classifies authentication and transient provider failures", async () => {
const authSource = createCloudflareIceServerSource(configuration, {
fetch: vi.fn(async () => response({}, 401)),
});
await expect(authSource.acquire(new AbortController().signal)).rejects.toMatchObject({
code: "authentication",
retryable: false,
});
const transientSource = createCloudflareIceServerSource(configuration, {
fetch: vi.fn(async () => response({}, 503)),
});
await expect(transientSource.acquire(new AbortController().signal)).rejects.toMatchObject({
code: "unavailable",
retryable: true,
});
});
it("propagates caller cancellation and turns a deadline into an unavailable failure", async () => {
const controller = new AbortController();
const fetch = vi.fn((_input: string | Request, init?: RequestInit) => {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), {
once: true,
});
});
});
const source = createCloudflareIceServerSource(configuration, { fetch });
const cancelled = source.acquire(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 assertion = expect(timed).rejects.toMatchObject({ code: "unavailable", retryable: true });
await vi.advanceTimersByTimeAsync(CLOUDFLARE_TURN_REQUEST_DEADLINE_MS);
await assertion;
});
it("rejects an issuance which has no usable remaining lifetime", async () => {
let now = 1_000_000;
const source = createCloudflareIceServerSource(configuration, {
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({
code: "invalid-response",
});
});
});
describe("Cloudflare ICE source validation", () => {
it("rejects unknown fields and malformed bearer credentials", () => {
expect(validateCloudflareIceServerSourceConfiguration({ ...configuration, unexpected: "value" })).toContain(
"unsupported field"
);
expect(
validateCloudflareIceServerSourceConfiguration({ turnKeyId: "key/id", apiToken: configuration.apiToken })
).toContain("unsupported characters");
expect(
validateCloudflareIceServerSourceConfiguration({ ...configuration, apiToken: "token with spaces" })
).toContain("Bearer token syntax");
});
});
+87
View File
@@ -0,0 +1,87 @@
/** The source identifier persisted in a P2P profile for Cloudflare TURN. */
export const CLOUDFLARE_ICE_SERVER_SOURCE_ID = "cloudflare" as const;
/** The lifetime requested from Cloudflare for each issued credential set. */
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 {
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.
const TURN_KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,255}$/;
// RFC 6750's b64token grammar, including optional trailing padding. This
// also excludes whitespace and control characters from the Authorization
// header without exposing the token in a validation message.
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.
* 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.";
}
const turnKeyId = value.turnKeyId;
if (typeof turnKeyId !== "string" || turnKeyId.length === 0) {
return "Enter a TURN Key ID.";
}
if (!TURN_KEY_ID_PATTERN.test(turnKeyId)) {
return "TURN Key ID contains unsupported characters.";
}
const apiToken = value.apiToken;
if (typeof apiToken !== "string" || apiToken.length === 0) {
return "Enter a TURN Key API Token.";
}
if (apiToken.length > MAX_BEARER_TOKEN_LENGTH || !BEARER_TOKEN_PATTERN.test(apiToken)) {
return "TURN Key API Token must use Bearer token syntax.";
}
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,
};
}
+85
View File
@@ -0,0 +1,85 @@
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);
}
/** Validate the selected settings projection, including an unavailable encrypted source. */
export function validateTurnSettings(settings: {
readonly P2P_iceServerSource?: IceServerSourceDescriptorLike | null;
readonly encryptedP2PIceServerSource?: string;
}): string | undefined {
if (!settings.P2P_iceServerSource && settings.encryptedP2PIceServerSource) {
return "TURN configuration could not be decrypted.";
}
return validateIceServerSourceConfiguration(settings.P2P_iceServerSource);
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import {
iceServerSourceDefinitions,
validateIceServerSourceConfiguration,
validateTurnSettings,
} from "./iceServerSources";
describe("ICE server source catalogue", () => {
it("blocks an unavailable encrypted source instead of presenting manual settings as valid", () => {
expect(validateTurnSettings({ encryptedP2PIceServerSource: "private-ciphertext" })).toBe(
"TURN configuration could not be decrypted."
);
expect(validateTurnSettings({})).toBeUndefined();
});
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"
);
});
});
+3 -1
View File
@@ -1,3 +1,4 @@
import { useIceServerSources } from "@/serviceFeatures/useIceServerSources";
import { getLanguage, Notice, Plugin, type App, type PluginManifest } from "./deps";
import { setGetLanguage } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
setGetLanguage(getLanguage);
@@ -182,7 +183,8 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
const replicator = useP2PReplicatorFeature(
core,
(_compatibilityReplicator, p2p) => createInteractiveP2PReplication(p2p),
createOpenRebuildUI(this.app)
createOpenRebuildUI(this.app),
{ iceServerSources: useIceServerSources(core.services.API.webCompatFetch.bind(core.services.API)) }
);
setupManager.registerP2PSetupConnectionProbe(replicator.connectionProbe);
useP2PReplicatorCommands(core, replicator);
@@ -1,3 +1,8 @@
import {
hasManagedTurnSettings,
omitManagedTurnProfilesFromMarkdown,
preserveManagedTurnProfilesOnMarkdownImport,
} from "@/common/turnSettingsPrivacy";
// import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser";
import { isObjectDifferent } from "octagonal-wheels/object";
import { EVENT_SETTING_SAVED, eventHub } from "@/common/events";
@@ -129,6 +134,7 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractModule {
let settingToApply = { ...DEFAULT_SETTINGS } as ObsidianLiveSyncSettings;
settingToApply = { ...settingToApply, ...newSetting };
preserveManagedTurnProfilesOnMarkdownImport(newSetting, this.settings, settingToApply);
if (!settingToApply?.writeCredentialsForSettingSync) {
//New setting does not contains credentials.
settingToApply.couchDB_USER = this.settings.couchDB_USER;
@@ -208,11 +214,18 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractModule {
delete saveData.couchDB_CustomHeaders;
delete saveData.bucketCustomHeaders;
}
omitManagedTurnProfilesFromMarkdown(saveData);
return saveData;
}
async saveSettingToMarkdown(filename: string) {
const saveData = this.generateSettingForMarkdown();
if (hasManagedTurnSettings(this.settings)) {
this._log(
"Share TURN provider credentials through an encrypted Setup URI. Connection profiles are omitted from Markdown settings.",
LOG_LEVEL_INFO
);
}
const file = await this.core.storageAccess.isExists(filename);
if (!file) {
@@ -1,3 +1,5 @@
import { copySetupURI } from "@/serviceFeatures/setupObsidian/setupUri";
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import {
REMOTE_COUCHDB,
REMOTE_MINIO,
@@ -416,6 +418,15 @@ export function paneRemoteConfig(
})
.addItem((item) => {
item.setTitle("📤 Export").onClick(async () => {
if (config.uri.startsWith("sls+p2p-v2://")) {
await copySetupURI(
this.core,
createInstanceLogFunction("TURN setup sharing", this.services.API),
true,
getSettingsFromEditingSettings(this.editingSettings)
);
return;
}
await this.services.UI.promptCopyToClipboard(
`Remote configuration: ${config.name}`,
config.uri
@@ -1,4 +1,6 @@
<script lang="ts">
import TurnConfiguration from "@/features/P2PSync/TurnConfiguration.svelte";
import { validateTurnSettings } from "@/integrations/iceServerSources";
// import { delay } from "octagonal-wheels/promises";
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
@@ -15,7 +17,7 @@
P2PMessageSizePresets,
PREFERRED_BASE,
RemoteTypes,
hasValidP2PTurnServerUrl,
hasP2PTurnConfiguration,
normaliseP2PConnectionPath,
normaliseP2PMaxWirePayloadBytes,
type EntryDoc,
@@ -27,7 +29,6 @@
import { TrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator";
import type { ReplicatorHostEnv } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/types";
import {
copyTo,
generateP2PRoomId,
pickP2PSyncSettings,
type SimpleStore,
@@ -51,7 +52,7 @@
const context = getDialogContext();
let error = $state("");
let connectionPathResetNotice = $state(false);
const hasValidTurnServer = $derived(hasValidP2PTurnServerUrl(syncSetting.P2P_turnServers ?? ""));
const hasValidTurnServer = $derived(hasP2PTurnConfiguration(syncSetting));
type Props = GuestDialogProps<SetupRemoteP2PResultType, SetupRemoteP2PInitialData>;
const { setResult, getInitialData }: Props = $props();
@@ -61,7 +62,7 @@
connectionProbe = initialData?.connectionProbe;
const initialSettings = initialData?.settings;
if (initialSettings) {
copyTo(initialSettings, syncSetting);
syncSetting = pickP2PSyncSettings(initialSettings);
}
const initialPeerName = (initialSettings?.P2P_DevicePeerName ?? "").trim();
if (initialPeerName !== "") {
@@ -100,6 +101,8 @@
async function checkConnection() {
try {
processing = true;
const sourceError = validateTurnSettings(syncSetting);
if (sourceError) return sourceError;
const trialRemoteSetting = generateSetting();
const admission = connectionProbe;
if (!admission) {
@@ -204,6 +207,8 @@
}
}
function commit() {
error = validateTurnSettings(syncSetting) ?? "";
if (error) return;
const setting = pickP2PSyncSettings(generateSetting());
setResult(setting);
}
@@ -215,7 +220,8 @@
syncSetting.P2P_relays.trim() !== "" &&
syncSetting.P2P_roomID.trim() !== "" &&
syncSetting.P2P_passphrase.trim() !== "" &&
(syncSetting.P2P_DevicePeerName ?? "").trim() !== ""
(syncSetting.P2P_DevicePeerName ?? "").trim() !== "" &&
validateTurnSettings(syncSetting) === undefined
);
});
</script>
@@ -339,24 +345,24 @@
</InputRow>
<InfoNote>
{translateMessage(
"TURN relay only is available when at least one valid TURN server URL is configured under Advanced Settings."
"TURN relay only requires a TURN server or a configured credential source under Advanced Settings."
)}
</InfoNote>
<InfoNote notice visible={connectionPathResetNotice}>
{translateMessage(
"TURN relay only requires at least one valid TURN server URL. Connection path has been restored to Automatic."
"TURN relay only requires TURN configuration. Connection path has been restored to Automatic."
)}
</InfoNote>
</ExtraItems>
<ExtraItems title={translateMessage("Advanced Settings")}>
<InfoNote>
{translateMessage(
"TURN server settings are only necessary if you are behind a strict NAT or firewall that prevents direct P2P connections. In most cases, you can leave these fields blank."
"Configure TURN when a direct connection cannot be established or when you select TURN relay only."
)}
</InfoNote>
<InfoNote warning>
<InfoNote>
{translateMessage(
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust."
"WebRTC encrypts data between your devices, including when it passes through TURN. The TURN provider cannot read the transferred data. It can see network addresses and traffic volume."
)}
<a
href="https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/p2p.md#signalling-relay-and-turn-server"
@@ -364,34 +370,7 @@
rel="noopener noreferrer">{translateMessage("Learn more about signalling and TURN")}</a
>.
</InfoNote>
<InputRow label={translateMessage("TURN Server URLs (comma-separated)")}>
<textarea
name="p2p-turn-servers"
placeholder="turn:turn.example.com:3478,turn:turn.example.com:443"
autocapitalize="off"
spellcheck="false"
bind:value={syncSetting.P2P_turnServers}
rows="5"
></textarea>
</InputRow>
<InputRow label={translateMessage("TURN Username")}>
<input
type="text"
name="p2p-turn-username"
placeholder={translateMessage("Enter TURN username")}
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={syncSetting.P2P_turnUsername}
/>
</InputRow>
<InputRow label={translateMessage("TURN Credential")}>
<Password
name="p2p-turn-credential"
placeholder={translateMessage("Enter TURN credential")}
bind:value={syncSetting.P2P_turnCredential}
/>
</InputRow>
<TurnConfiguration bind:settings={syncSetting} />
</ExtraItems>
<InfoNote error visible={error !== ""}>
{error}
@@ -1,6 +1,5 @@
<script lang="ts">
import { configURIBase } from "@/common/types";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { configURIBase, configURIBaseV2 } from "@/common/types";
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
@@ -10,7 +9,7 @@
import Password from "@/modules/services/LiveSyncUI/components/Password.svelte";
import { onMount } from "svelte";
import { decryptString } from "@vrtmrz/livesync-commonlib/compat/encryption/stringEncryption";
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import type { GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
import { TYPE_CANCELLED, type UseSetupURIResultType } from "./setupDialogTypes";
import { $msg as translateMessage } from "@/common/translation";
@@ -30,7 +29,7 @@
}
});
const seemsValid = $derived.by(() => setupURI.startsWith(configURIBase));
const seemsValid = $derived(setupURI.startsWith(configURIBase) || setupURI.startsWith(configURIBaseV2));
async function processSetupURI() {
error = "";
if (!seemsValid) return;
@@ -39,11 +38,8 @@
return;
}
try {
const settingPieces = setupURI.substring(configURIBase.length);
const encodedConfig = decodeURIComponent(settingPieces);
const newConf = (await JSON.parse(
await decryptString(encodedConfig, passphrase)
)) as ObsidianLiveSyncSettings;
const newConf = await decodeSettingsFromSetupURI(setupURI.trim(), passphrase);
if (!newConf) throw new Error("Invalid Setup URI settings");
setResult(newConf);
// Logger("Settings imported successfully", LOG_LEVEL_NOTICE);
return;
+9 -1
View File
@@ -1,3 +1,6 @@
import { hasManagedTurnSettings } from "@/common/turnSettingsPrivacy";
import { copySetupURI } from "./setupUri";
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import {
encodeQR,
@@ -9,7 +12,12 @@ import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import type { SetupFeatureHost } from "./types";
export async function encodeSetupSettingsAsQR(host: SetupFeatureHost) {
const settingString = encodeSettingsToQRCodeData(host.services.setting.currentSettings());
const settings = host.services.setting.currentSettings();
if (hasManagedTurnSettings(settings)) {
await copySetupURI(host, createInstanceLogFunction("SF:SetupQRCode", host.services.API));
return "";
}
const settingString = encodeSettingsToQRCodeData(settings);
const result = encodeQR(settingString, OutputFormat.SVG);
if (result === "") {
return "";
@@ -3,6 +3,9 @@ import { EVENT_REQUEST_SHOW_SETUP_QR } from "@vrtmrz/livesync-commonlib/compat/e
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { encodeSetupSettingsAsQR, useSetupQRCodeFeature } from "./qrCode";
import { encodeQR, encodeSettingsToQRCodeData } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import { copySetupURI } from "./setupUri";
vi.mock("./setupUri", () => ({ copySetupURI: vi.fn() }));
vi.mock("@vrtmrz/livesync-commonlib/compat/API/processSetting", () => {
return {
@@ -15,6 +18,17 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/API/processSetting", () => {
});
describe("setupObsidian/qrCode", () => {
it("routes inactive managed profiles through encrypted Setup URI sharing", async () => {
const settings = {
remoteConfigurations: { managed: { uri: "sls+p2p-v2://room?source=private-token" } },
};
const host = { services: { API: { addLog: vi.fn() }, setting: { currentSettings: () => settings } } } as any;
await encodeSetupSettingsAsQR(host);
expect(copySetupURI).toHaveBeenCalledWith(host, expect.any(Function));
expect(encodeSettingsToQRCodeData).not.toHaveBeenCalled();
expect(encodeQR).not.toHaveBeenCalled();
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
@@ -2,20 +2,23 @@ import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import type { SetupFeatureHost } from "@/serviceFeatures/setupObsidian/types";
import { configURIBase } from "@/common/types";
import { configURIBase, configURIBaseV2 } from "@/common/types";
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import { type SetupManager, UserMode } from "@/modules/features/SetupManager";
async function handleSetupProtocol(setupManager: SetupManager, conf: Record<string, string>) {
async function handleSetupProtocol(setupManager: SetupManager, conf: Record<string, string>, uriBase = configURIBase) {
if (conf.settings) {
await setupManager.onUseSetupURI(UserMode.Unknown, `${configURIBase}${encodeURIComponent(conf.settings)}`);
} else if (conf.settingsQR) {
await setupManager.onUseSetupURI(UserMode.Unknown, `${uriBase}${encodeURIComponent(conf.settings)}`);
} else if (conf.settingsQR && uriBase === configURIBase) {
await setupManager.decodeQR(conf.settingsQR);
}
}
export function registerSetupProtocolHandler(host: SetupFeatureHost, log: LogFunction, setupManager: SetupManager) {
try {
host.services.API.registerProtocolHandler("setuplivesync-v2", async (conf) => {
await handleSetupProtocol(setupManager, conf, configURIBaseV2);
});
host.services.API.registerProtocolHandler("setuplivesync", async (conf) => {
await handleSetupProtocol(setupManager, conf);
});
@@ -4,6 +4,7 @@ import { registerSetupProtocolHandler, useSetupProtocolFeature } from "./setupPr
vi.mock("@/common/types", () => {
return {
configURIBase: "mock-config://",
configURIBaseV2: "mock-config-v2://",
};
});
@@ -17,6 +18,19 @@ vi.mock("@/modules/features/SetupManager", () => {
});
describe("setupObsidian/setupProtocol", () => {
it("routes the versioned encrypted payload through the matching URI format", async () => {
const handlers = new Map<string, (params: Record<string, string>) => Promise<void>>();
const host = {
services: { API: { registerProtocolHandler: vi.fn((action, handler) => handlers.set(action, handler)) } },
} as any;
const setupManager = { onUseSetupURI: vi.fn(), decodeQR: vi.fn() } as any;
registerSetupProtocolHandler(host, vi.fn(), setupManager);
await handlers.get("setuplivesync-v2")!({ settings: "encrypted settings" });
expect(setupManager.onUseSetupURI).toHaveBeenCalledWith("unknown", "mock-config-v2://encrypted%20settings");
await handlers.get("setuplivesync-v2")!({ settingsQR: "plain settings" });
expect(setupManager.decodeQR).not.toHaveBeenCalled();
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
@@ -16,11 +16,16 @@ export async function askEncryptingPassphrase(host: SetupFeatureHost): Promise<s
);
}
export async function copySetupURI(host: SetupFeatureHost, log: LogFunction, stripExtra = true) {
export async function copySetupURI(
host: SetupFeatureHost,
log: LogFunction,
stripExtra = true,
settings = host.services.setting.currentSettings()
) {
const encryptingPassphrase = await askEncryptingPassphrase(host);
if (encryptingPassphrase === false) return;
const encryptedURI = await encodeSettingsToSetupURI(
host.services.setting.currentSettings(),
settings,
encryptingPassphrase,
[...((stripExtra ? ["pluginSyncExtendedSetting"] : []) as (keyof ObsidianLiveSyncSettings)[])],
true
@@ -0,0 +1,17 @@
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 }),
};
}