Use profile storage for TURN source settings

This commit is contained in:
vorotamoroz
2026-09-15 18:28:20 +00:00
parent ab55eb5aff
commit a565070809
11 changed files with 42 additions and 57 deletions
+10 -8
View File
@@ -1,6 +1,6 @@
---
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"
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 profile so that restarting a device and configuring another device do
not require re-entry. This does not claim operating-system keychain storage.
When optional configuration encryption is enabled, cover both the saved
profile URI and any top-level settings projection containing the source.
Failure to encrypt either copy must leave the prior saved settings intact
and report a safe error; it must not silently save a plaintext replacement.
Persist the source only in the ordinary P2P profile URI, covered by the
existing optional configuration encryption. The top-level source is an
in-memory and sharing projection restored when the selected profile is
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 |
| --- | --- | --- |
@@ -419,9 +421,9 @@ manual TURN when an explicitly configured source is unsupported.
Persist the ordinary P2P settings projection alongside its profile. Keep the
Group ID, enabled state, and autostart preference consistent with the current
settings. Optional configuration encryption continues to protect both the
profile and its source configuration. Issued credentials never populate the
persisted manual TURN fields.
settings. The source configuration is persisted only inside the profile URI
and restored by the existing profile activation. Issued credentials never
populate the persisted manual TURN fields.
The P2P data protocol and Group ID remain unchanged. A peer using manually
configured TURN can communicate with one using issued credentials; validate
+4 -4
View File
@@ -23,7 +23,7 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@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/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
@@ -4567,9 +4567,9 @@
}
},
"node_modules/@vrtmrz/livesync-commonlib": {
"version": "0.1.25-dev.turn-credentials.4",
"resolved": "file:.devmemo.local/packages/vrtmrz-livesync-commonlib-0.1.25-dev.turn-credentials.4.tgz",
"integrity": "sha512-q8jFJYzBzp7fsE+fU48A8gTvQ5DnJexkhReV8hUu2vhZvlV0e2ywUpPxkzx5KNNwSVpLSX96JCHRBLUvDMXKUQ==",
"version": "0.1.25-dev.turn-credentials.5",
"resolved": "file:.devmemo.local/packages/vrtmrz-livesync-commonlib-0.1.25-dev.turn-credentials.5.tgz",
"integrity": "sha512-mAKjoJoXKMl1ts8mUTvYsjJy4bluGykBCvwXDgjtY7cLrG6ct0iRrlyVrLs7GY7xnVnWeNmu/vj12D9IMzEpCQ==",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.808.0",
+1 -1
View File
@@ -181,7 +181,7 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@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/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
@@ -5,23 +5,24 @@
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";
import { validateIceServerSourceConfiguration } from "@/integrations/iceServerSources";
let { host }: { host: P2PReplicatorPaneHost } = $props();
const currentSettings = () => host.services.setting.currentSettings() as P2PSyncSetting;
function turnSettings(settings: P2PSyncSetting) {
return {
P2P_roomID: settings.P2P_roomID,
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));
const sourceError = $derived(validateIceServerSourceConfiguration(draft.P2P_iceServerSource));
const sourceNeedsRoom = $derived(!!draft.P2P_iceServerSource && (draft.P2P_roomID ?? "").trim() === "");
function loadSettings(settings: P2PSyncSetting): void {
const next = turnSettings(settings);
@@ -31,7 +32,7 @@
onMount(() => host.services.context.events.onEvent("setting-saved", (settings) => loadSettings(settings as P2PSyncSetting)));
async function save(): Promise<void> {
if (sourceError) return;
if (sourceError || sourceNeedsRoom) return;
const values = $state.snapshot(draft);
await host.services.setting.updateSettings((settings) => {
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>
<TurnConfiguration bind:settings={draft} />
<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
</button>
<button type="button" class="button" disabled={!isModified} onclick={() => loadSettings(currentSettings())}>
@@ -9,7 +9,6 @@
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",
+1 -4
View File
@@ -10,14 +10,13 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({
}));
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 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",
@@ -33,9 +32,7 @@ describe("TURN credentials in diagnostic reports", () => {
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://");
expect(settings.P2P_iceServerSource).toEqual(source);
expect(settings.encryptedP2PIceServerSource).toBe("encrypted-private-copy");
});
});
+12 -5
View File
@@ -1,15 +1,24 @@
import {
hasManagedP2PIceServerSource as hasManagedTurnSettings,
hasManagedP2PIceServerSource,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
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. */
export function redactTurnSourceForReport(settings: Partial<ObsidianLiveSyncSettings>): void {
if (settings.encryptedP2PIceServerSource) settings.encryptedP2PIceServerSource = "REDACTED";
if (settings.P2P_iceServerSource !== undefined) {
settings.P2P_iceServerSource = {
version: 1,
@@ -25,7 +34,6 @@ export function redactTurnSourceForReport(settings: Partial<ObsidianLiveSyncSett
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;
@@ -48,5 +56,4 @@ export function preserveManagedTurnProfilesOnMarkdownImport(
merged.activeConfigurationId = current.activeConfigurationId;
merged.P2P_ActiveRemoteConfigurationId = current.P2P_ActiveRemoteConfigurationId;
merged.P2P_iceServerSource = structuredClone(current.P2P_iceServerSource);
merged.encryptedP2PIceServerSource = current.encryptedP2PIceServerSource;
}
@@ -1,21 +1,19 @@
<script lang="ts">
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";
type TurnSettings = Pick<P2PConnectionInfo,
"P2P_turnServers" | "P2P_turnUsername" | "P2P_turnCredential" | "P2P_iceServerSource" | "encryptedP2PIceServerSource">;
type TurnSettings = Pick<P2PConnectionInfo, "P2P_turnServers" | "P2P_turnUsername" | "P2P_turnCredential" | "P2P_iceServerSource">;
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 error = $derived(validateTurnSettings(settings));
const error = $derived(validateIceServerSourceConfiguration(settings.P2P_iceServerSource));
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 {
-11
View File
@@ -72,14 +72,3 @@ export function validateIceServerSourceConfiguration(
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);
}
@@ -2,17 +2,9 @@ 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([
{
@@ -1,6 +1,6 @@
<script lang="ts">
import TurnConfiguration from "@/features/P2PSync/TurnConfiguration.svelte";
import { validateTurnSettings } from "@/integrations/iceServerSources";
import { validateIceServerSourceConfiguration } 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";
@@ -101,7 +101,7 @@
async function checkConnection() {
try {
processing = true;
const sourceError = validateTurnSettings(syncSetting);
const sourceError = validateIceServerSourceConfiguration(syncSetting.P2P_iceServerSource);
if (sourceError) return sourceError;
const trialRemoteSetting = generateSetting();
const admission = connectionProbe;
@@ -207,7 +207,7 @@
}
}
function commit() {
error = validateTurnSettings(syncSetting) ?? "";
error = validateIceServerSourceConfiguration(syncSetting.P2P_iceServerSource) ?? "";
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() !== "" &&
validateTurnSettings(syncSetting) === undefined
validateIceServerSourceConfiguration(syncSetting.P2P_iceServerSource) === undefined
);
});
</script>