Use profile storage for TURN source settings

This commit is contained in:
vorotamoroz committed 2026-09-15 18:28:20 +00:00
1 parent ab55eb5aff
commit a565070809
11 files changed
+42 -57

No files matched your search

+10 -8
View File
@@ -1,6 +1,6 @@
--- ---
date: 2026-09-15 date: 2026-09-15
commonlib-version: "0.1.25-dev.turn-credentials.4" commonlib-version: "0.1.25-dev.turn-credentials.5"
self-hosted-livesync-version: "1.0.28" self-hosted-livesync-version: "1.0.28"
status: unreleased status: unreleased
--- ---
@@ -143,10 +143,12 @@ and expired runtime credentials through the existing room replacement policy.
The API token is an ordinary sensitive connection setting. Persist it with The API token is an ordinary sensitive connection setting. Persist it with
the profile so that restarting a device and configuring another device do the profile so that restarting a device and configuring another device do
not require re-entry. This does not claim operating-system keychain storage. not require re-entry. This does not claim operating-system keychain storage.
When optional configuration encryption is enabled, cover both the saved Persist the source only in the ordinary P2P profile URI, covered by the
profile URI and any top-level settings projection containing the source. existing optional configuration encryption. The top-level source is an
Failure to encrypt either copy must leave the prior saved settings intact in-memory and sharing projection restored when the selected profile is
and report a safe error; it must not silently save a plaintext replacement. activated. A source draft without a Group ID is not persisted. Failure to
encrypt a managed profile must leave the prior saved settings intact and
report a safe error; it must not silently save a plaintext replacement.
| Destination | Provider API token | Issued TURN username and credential | | Destination | Provider API token | Issued TURN username and credential |
| --- | --- | --- | | --- | --- | --- |
@@ -419,9 +421,9 @@ manual TURN when an explicitly configured source is unsupported.
Persist the ordinary P2P settings projection alongside its profile. Keep the Persist the ordinary P2P settings projection alongside its profile. Keep the
Group ID, enabled state, and autostart preference consistent with the current Group ID, enabled state, and autostart preference consistent with the current
settings. Optional configuration encryption continues to protect both the settings. The source configuration is persisted only inside the profile URI
profile and its source configuration. Issued credentials never populate the and restored by the existing profile activation. Issued credentials never
persisted manual TURN fields. populate the persisted manual TURN fields.
The P2P data protocol and Group ID remain unchanged. A peer using manually The P2P data protocol and Group ID remain unchanged. A peer using manually
configured TURN can communicate with one using issued credentials; validate configured TURN can communicate with one using issued credentials; validate
+4 -4
View File
@@ -23,7 +23,7 @@
"@smithy/types": "^4.14.3", "@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5", "@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0", "@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "file:.devmemo.local/packages/vrtmrz-livesync-commonlib-0.1.25-dev.turn-credentials.4.tgz", "@vrtmrz/livesync-commonlib": "file:.devmemo.local/packages/vrtmrz-livesync-commonlib-0.1.25-dev.turn-credentials.5.tgz",
"@vrtmrz/obsidian-plugin-kit": "0.1.4", "@vrtmrz/obsidian-plugin-kit": "0.1.4",
"@vrtmrz/ui-interactions": "0.1.2", "@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
@@ -4567,9 +4567,9 @@
} }
}, },
"node_modules/@vrtmrz/livesync-commonlib": { "node_modules/@vrtmrz/livesync-commonlib": {
"version": "0.1.25-dev.turn-credentials.4", "version": "0.1.25-dev.turn-credentials.5",
"resolved": "file:.devmemo.local/packages/vrtmrz-livesync-commonlib-0.1.25-dev.turn-credentials.4.tgz", "resolved": "file:.devmemo.local/packages/vrtmrz-livesync-commonlib-0.1.25-dev.turn-credentials.5.tgz",
"integrity": "sha512-q8jFJYzBzp7fsE+fU48A8gTvQ5DnJexkhReV8hUu2vhZvlV0e2ywUpPxkzx5KNNwSVpLSX96JCHRBLUvDMXKUQ==", "integrity": "sha512-mAKjoJoXKMl1ts8mUTvYsjJy4bluGykBCvwXDgjtY7cLrG6ct0iRrlyVrLs7GY7xnVnWeNmu/vj12D9IMzEpCQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.808.0", "@aws-sdk/client-s3": "^3.808.0",
+1 -1
View File
@@ -181,7 +181,7 @@
"@smithy/types": "^4.14.3", "@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5", "@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0", "@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "file:.devmemo.local/packages/vrtmrz-livesync-commonlib-0.1.25-dev.turn-credentials.4.tgz", "@vrtmrz/livesync-commonlib": "file:.devmemo.local/packages/vrtmrz-livesync-commonlib-0.1.25-dev.turn-credentials.5.tgz",
"@vrtmrz/obsidian-plugin-kit": "0.1.4", "@vrtmrz/obsidian-plugin-kit": "0.1.4",
"@vrtmrz/ui-interactions": "0.1.2", "@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
@@ -5,23 +5,24 @@
import type { P2PSyncSetting } 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 type { P2PReplicatorPaneHost } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost";
import TurnConfiguration from "@/features/P2PSync/TurnConfiguration.svelte"; import TurnConfiguration from "@/features/P2PSync/TurnConfiguration.svelte";
import { validateTurnSettings } from "@/integrations/iceServerSources"; import { validateIceServerSourceConfiguration } from "@/integrations/iceServerSources";
let { host }: { host: P2PReplicatorPaneHost } = $props(); let { host }: { host: P2PReplicatorPaneHost } = $props();
const currentSettings = () => host.services.setting.currentSettings() as P2PSyncSetting; const currentSettings = () => host.services.setting.currentSettings() as P2PSyncSetting;
function turnSettings(settings: P2PSyncSetting) { function turnSettings(settings: P2PSyncSetting) {
return { return {
P2P_roomID: settings.P2P_roomID,
P2P_turnServers: settings.P2P_turnServers, P2P_turnServers: settings.P2P_turnServers,
P2P_turnUsername: settings.P2P_turnUsername, P2P_turnUsername: settings.P2P_turnUsername,
P2P_turnCredential: settings.P2P_turnCredential, P2P_turnCredential: settings.P2P_turnCredential,
P2P_iceServerSource: structuredClone(settings.P2P_iceServerSource), P2P_iceServerSource: structuredClone(settings.P2P_iceServerSource),
encryptedP2PIceServerSource: settings.encryptedP2PIceServerSource,
}; };
} }
let draft = $state(turnSettings(currentSettings())); let draft = $state(turnSettings(currentSettings()));
let saved = $state(JSON.stringify(turnSettings(currentSettings()))); let saved = $state(JSON.stringify(turnSettings(currentSettings())));
const isModified = $derived(JSON.stringify(draft) !== saved); const isModified = $derived(JSON.stringify(draft) !== saved);
const sourceError = $derived(validateTurnSettings(draft)); const sourceError = $derived(validateIceServerSourceConfiguration(draft.P2P_iceServerSource));
const sourceNeedsRoom = $derived(!!draft.P2P_iceServerSource && (draft.P2P_roomID ?? "").trim() === "");
function loadSettings(settings: P2PSyncSetting): void { function loadSettings(settings: P2PSyncSetting): void {
const next = turnSettings(settings); const next = turnSettings(settings);
@@ -31,7 +32,7 @@
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> { async function save(): Promise<void> {
if (sourceError) return; if (sourceError || sourceNeedsRoom) return;
const values = $state.snapshot(draft); const values = $state.snapshot(draft);
await host.services.setting.updateSettings((settings) => { await host.services.setting.updateSettings((settings) => {
const next = { ...settings, ...values, remoteConfigurations: { ...settings.remoteConfigurations } }; const next = { ...settings, ...values, remoteConfigurations: { ...settings.remoteConfigurations } };
@@ -52,7 +53,7 @@
<p>Configure TURN only when a direct peer-to-peer connection cannot be established.</p> <p>Configure TURN only when a direct peer-to-peer connection cannot be established.</p>
<TurnConfiguration bind:settings={draft} /> <TurnConfiguration bind:settings={draft} />
<div class="actions"> <div class="actions">
<button type="button" class="button mod-cta" disabled={!isModified || !!sourceError} onclick={save}> <button type="button" class="button mod-cta" disabled={!isModified || !!sourceError || sourceNeedsRoom} onclick={save}>
Save TURN settings Save TURN settings
</button> </button>
<button type="button" class="button" disabled={!isModified} onclick={() => loadSettings(currentSettings())}> <button type="button" class="button" disabled={!isModified} onclick={() => loadSettings(currentSettings())}>
@@ -9,7 +9,6 @@
export const liveSyncProvisionalEnglishMessages = { 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.":
"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", "TURN configuration": "TURN configuration",
Manual: "Manual", Manual: "Manual",
Cloudflare: "Cloudflare", Cloudflare: "Cloudflare",
+1 -4
View File
@@ -10,14 +10,13 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({
})); }));
describe("TURN credentials in diagnostic reports", () => { describe("TURN credentials in diagnostic reports", () => {
it("redacts top-level, encrypted, and inactive encoded source copies", async () => { it("redacts top-level and inactive encoded source copies", async () => {
const token = "private+token/with=symbols"; const token = "private+token/with=symbols";
const source = { version: 1, id: "cloudflare", configuration: { turnKeyId: "private-key", apiToken: token } }; const source = { version: 1, id: "cloudflare", configuration: { turnKeyId: "private-key", apiToken: token } };
const settings = { const settings = {
...DEFAULT_SETTINGS, ...DEFAULT_SETTINGS,
remoteType: REMOTE_P2P, remoteType: REMOTE_P2P,
P2P_iceServerSource: source, P2P_iceServerSource: source,
encryptedP2PIceServerSource: "encrypted-private-copy",
remoteConfigurations: { remoteConfigurations: {
inactive: { inactive: {
id: "inactive", id: "inactive",
@@ -33,9 +32,7 @@ describe("TURN credentials in diagnostic reports", () => {
expect(text).not.toContain(token); expect(text).not.toContain(token);
expect(text).not.toContain(encodeURIComponent(token)); expect(text).not.toContain(encodeURIComponent(token));
expect(text).not.toContain("private-key"); expect(text).not.toContain("private-key");
expect(text).not.toContain("encrypted-private-copy");
expect(report.pluginConfig.remoteConfigurations.inactive.uri).toBe("sls+p2p://"); expect(report.pluginConfig.remoteConfigurations.inactive.uri).toBe("sls+p2p://");
expect(settings.P2P_iceServerSource).toEqual(source); expect(settings.P2P_iceServerSource).toEqual(source);
expect(settings.encryptedP2PIceServerSource).toBe("encrypted-private-copy");
}); });
}); });
+12 -5
View File
@@ -1,15 +1,24 @@
import { import {
hasManagedP2PIceServerSource as hasManagedTurnSettings, hasManagedP2PIceServerSource,
type ObsidianLiveSyncSettings, type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types"; } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { iceServerSourceDefinitions } from "@/integrations/iceServerSources"; import { iceServerSourceDefinitions } from "@/integrations/iceServerSources";
export { hasManagedTurnSettings }; /** Include inactive profiles when deciding whether Markdown would disclose source settings. */
export function hasManagedTurnSettings(settings: Partial<ObsidianLiveSyncSettings>): boolean {
return (
hasManagedP2PIceServerSource(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");
})
);
}
/** Reports retain the selected source label, but no opaque source configuration. */ /** Reports retain the selected source label, but no opaque source configuration. */
export function redactTurnSourceForReport(settings: Partial<ObsidianLiveSyncSettings>): void { export function redactTurnSourceForReport(settings: Partial<ObsidianLiveSyncSettings>): void {
if (settings.encryptedP2PIceServerSource) settings.encryptedP2PIceServerSource = "REDACTED";
if (settings.P2P_iceServerSource !== undefined) { if (settings.P2P_iceServerSource !== undefined) {
settings.P2P_iceServerSource = { settings.P2P_iceServerSource = {
version: 1, version: 1,
@@ -25,7 +34,6 @@ export function redactTurnSourceForReport(settings: Partial<ObsidianLiveSyncSett
export function omitManagedTurnProfilesFromMarkdown(settings: Partial<ObsidianLiveSyncSettings>): void { export function omitManagedTurnProfilesFromMarkdown(settings: Partial<ObsidianLiveSyncSettings>): void {
if (!hasManagedTurnSettings(settings)) return; if (!hasManagedTurnSettings(settings)) return;
delete settings.P2P_iceServerSource; delete settings.P2P_iceServerSource;
delete settings.encryptedP2PIceServerSource;
delete settings.remoteConfigurations; delete settings.remoteConfigurations;
delete settings.activeConfigurationId; delete settings.activeConfigurationId;
delete settings.P2P_ActiveRemoteConfigurationId; delete settings.P2P_ActiveRemoteConfigurationId;
@@ -48,5 +56,4 @@ export function preserveManagedTurnProfilesOnMarkdownImport(
merged.activeConfigurationId = current.activeConfigurationId; merged.activeConfigurationId = current.activeConfigurationId;
merged.P2P_ActiveRemoteConfigurationId = current.P2P_ActiveRemoteConfigurationId; merged.P2P_ActiveRemoteConfigurationId = current.P2P_ActiveRemoteConfigurationId;
merged.P2P_iceServerSource = structuredClone(current.P2P_iceServerSource); merged.P2P_iceServerSource = structuredClone(current.P2P_iceServerSource);
merged.encryptedP2PIceServerSource = current.encryptedP2PIceServerSource;
} }
@@ -1,21 +1,19 @@
<script lang="ts"> <script lang="ts">
import type { P2PConnectionInfo } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { P2PConnectionInfo } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { iceServerSourceDefinitions, validateTurnSettings } from "@/integrations/iceServerSources"; import { iceServerSourceDefinitions, validateIceServerSourceConfiguration } from "@/integrations/iceServerSources";
import { translateLiveSyncMessage as translate, translateIfAvailable } from "@/common/translation"; import { translateLiveSyncMessage as translate, translateIfAvailable } from "@/common/translation";
type TurnSettings = Pick<P2PConnectionInfo, type TurnSettings = Pick<P2PConnectionInfo, "P2P_turnServers" | "P2P_turnUsername" | "P2P_turnCredential" | "P2P_iceServerSource">;
"P2P_turnServers" | "P2P_turnUsername" | "P2P_turnCredential" | "P2P_iceServerSource" | "encryptedP2PIceServerSource">;
let { settings = $bindable() }: { settings: TurnSettings } = $props(); let { settings = $bindable() }: { settings: TurnSettings } = $props();
const sourceId = $derived(settings.P2P_iceServerSource?.id ?? (settings.encryptedP2PIceServerSource ? "unavailable" : "manual")); const sourceId = $derived(settings.P2P_iceServerSource?.id ?? "manual");
const definition = $derived(iceServerSourceDefinitions.find((source) => source.id === sourceId)); const definition = $derived(iceServerSourceDefinitions.find((source) => source.id === sourceId));
const error = $derived(validateTurnSettings(settings)); const error = $derived(validateIceServerSourceConfiguration(settings.P2P_iceServerSource));
function selectSource(id: string) { function selectSource(id: string) {
const selected = iceServerSourceDefinitions.find((source) => source.id === id); const selected = iceServerSourceDefinitions.find((source) => source.id === id);
settings.P2P_iceServerSource = selected settings.P2P_iceServerSource = selected
? { version: 1, id, configuration: Object.fromEntries(selected.fields.map((field) => [field.key, ""])) } ? { version: 1, id, configuration: Object.fromEntries(selected.fields.map((field) => [field.key, ""])) }
: undefined; : undefined;
settings.encryptedP2PIceServerSource = "";
} }
function fieldValue(key: string): string { function fieldValue(key: string): string {
-11
View File
@@ -72,14 +72,3 @@ export function validateIceServerSourceConfiguration(
export function getIceServerSourceDefinition(id: string): IceServerSourceDefinition | undefined { export function getIceServerSourceDefinition(id: string): IceServerSourceDefinition | undefined {
return iceServerSourceDefinitions.find((definition) => definition.id === id); 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);
}
@@ -2,17 +2,9 @@ import { describe, expect, it } from "vitest";
import { import {
iceServerSourceDefinitions, iceServerSourceDefinitions,
validateIceServerSourceConfiguration, validateIceServerSourceConfiguration,
validateTurnSettings,
} from "./iceServerSources"; } from "./iceServerSources";
describe("ICE server source catalogue", () => { 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", () => { it("describes the Cloudflare fields without owning manual TURN fields", () => {
expect(iceServerSourceDefinitions).toEqual([ expect(iceServerSourceDefinitions).toEqual([
{ {
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import TurnConfiguration from "@/features/P2PSync/TurnConfiguration.svelte"; import TurnConfiguration from "@/features/P2PSync/TurnConfiguration.svelte";
import { validateTurnSettings } from "@/integrations/iceServerSources"; import { validateIceServerSourceConfiguration } from "@/integrations/iceServerSources";
// import { delay } from "octagonal-wheels/promises"; // import { delay } from "octagonal-wheels/promises";
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte"; import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte"; import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
@@ -101,7 +101,7 @@
async function checkConnection() { async function checkConnection() {
try { try {
processing = true; processing = true;
const sourceError = validateTurnSettings(syncSetting); const sourceError = validateIceServerSourceConfiguration(syncSetting.P2P_iceServerSource);
if (sourceError) return sourceError; if (sourceError) return sourceError;
const trialRemoteSetting = generateSetting(); const trialRemoteSetting = generateSetting();
const admission = connectionProbe; const admission = connectionProbe;
@@ -207,7 +207,7 @@
} }
} }
function commit() { function commit() {
error = validateTurnSettings(syncSetting) ?? ""; error = validateIceServerSourceConfiguration(syncSetting.P2P_iceServerSource) ?? "";
if (error) return; if (error) return;
const setting = pickP2PSyncSettings(generateSetting()); const setting = pickP2PSyncSettings(generateSetting());
setResult(setting); setResult(setting);
@@ -221,7 +221,7 @@
syncSetting.P2P_roomID.trim() !== "" && syncSetting.P2P_roomID.trim() !== "" &&
syncSetting.P2P_passphrase.trim() !== "" && syncSetting.P2P_passphrase.trim() !== "" &&
(syncSetting.P2P_DevicePeerName ?? "").trim() !== "" && (syncSetting.P2P_DevicePeerName ?? "").trim() !== "" &&
validateTurnSettings(syncSetting) === undefined validateIceServerSourceConfiguration(syncSetting.P2P_iceServerSource) === undefined
); );
}); });
</script> </script>