Use host preparation for TURN connection settings

This commit is contained in:
vorotamoroz
2026-09-16 03:43:09 +00:00
parent a565070809
commit 11a07b26af
33 changed files with 654 additions and 961 deletions
@@ -21,17 +21,11 @@ export const liveSyncProvisionalEnglishMessages = {
"TURN relay only requires a TURN server or a configured credential source under Advanced Settings.",
"TURN relay only requires TURN configuration. Connection path has been restored to Automatic.":
"TURN relay only requires TURN configuration. Connection path has been restored to Automatic.",
"Cloudflare TURN configuration is invalid.": "Cloudflare TURN configuration is invalid.",
"Cloudflare TURN configuration contains an unsupported field.":
"Cloudflare TURN configuration contains an unsupported field.",
"Enter a TURN Key ID.": "Enter a TURN Key ID.",
"TURN Key ID contains unsupported characters.": "TURN Key ID contains unsupported characters.",
"Enter a TURN Key API Token.": "Enter a TURN Key API Token.",
"TURN Key API Token must use Bearer token syntax.": "TURN Key API Token must use Bearer token syntax.",
"TURN configuration source version is not supported.": "TURN configuration source version is not supported.",
"TURN configuration source is invalid.": "TURN configuration source is invalid.",
"The selected TURN configuration source is not supported.":
"The selected TURN configuration source is not supported.",
"The selected TURN configuration is not supported.": "The selected TURN configuration is not supported.",
"Setup Complete: Preparing to Fetch from Another Device": "Setup Complete: Preparing to Fetch from Another Device",
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.":
+2 -2
View File
@@ -1,4 +1,4 @@
import { redactTurnSourceForReport } from "./turnSettingsPrivacy";
import { redactTurnSettingsForReport } from "./turnSettingsPrivacy";
import { REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/settings";
import { generateCredentialObject } from "@vrtmrz/livesync-commonlib/compat/replication/httplib";
@@ -68,7 +68,7 @@ export async function generateReport(settings: ObsidianLiveSyncSettings, core: L
delete pluginConfig[key as keyof ObsidianLiveSyncSettings];
}
redactTurnSourceForReport(pluginConfig);
redactTurnSettingsForReport(pluginConfig);
pluginConfig.couchDB_DBNAME = REDACTED;
pluginConfig.couchDB_PASSWORD = REDACTED;
const scheme = pluginConfig.couchDB_URI.startsWith("http:")
+8 -5
View File
@@ -10,19 +10,21 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({
}));
describe("TURN credentials in diagnostic reports", () => {
it("redacts top-level and inactive encoded source copies", async () => {
it("redacts provider tokens in all profiles and runtime credentials", async () => {
const token = "private+token/with=symbols";
const source = { version: 1, id: "cloudflare", configuration: { turnKeyId: "private-key", apiToken: token } };
const provider = { P2P_managedType: "CF", P2P_managedId: "private-key", P2P_managedToken: token };
const settings = {
...DEFAULT_SETTINGS,
remoteType: REMOTE_P2P,
P2P_iceServerSource: source,
...provider,
P2P_iceServers: [{ urls: "turn:example.test", username: "issued-user", credential: "issued-password" }],
P2P_iceServersExpiresAt: 123456789,
remoteConfigurations: {
inactive: {
id: "inactive",
name: "Inactive TURN",
isEncrypted: false,
uri: `sls+p2p://room?source=${encodeURIComponent(JSON.stringify(source))}`,
uri: `sls+p2p://room?managedType=CF&managedId=private-key&token=${encodeURIComponent(token)}`,
},
},
};
@@ -33,6 +35,7 @@ describe("TURN credentials in diagnostic reports", () => {
expect(text).not.toContain(encodeURIComponent(token));
expect(text).not.toContain("private-key");
expect(report.pluginConfig.remoteConfigurations.inactive.uri).toBe("sls+p2p://");
expect(settings.P2P_iceServerSource).toEqual(source);
expect(settings.P2P_managedToken).toBe(token);
expect(text).not.toMatch(/issued-user|issued-password|P2P_iceServers/);
});
});
+26 -22
View File
@@ -1,45 +1,50 @@
import {
hasManagedP2PIceServerSource,
hasManagedP2PTurnConfiguration,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { pickP2PSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { CLOUDFLARE_TURN_TYPE } from "@/integrations/cloudflare/settings";
import { iceServerSourceDefinitions } from "@/integrations/iceServerSources";
/** Include inactive profiles when deciding whether Markdown would disclose source settings. */
/** Include inactive profiles when deciding whether Markdown would disclose provider settings. */
export function hasManagedTurnSettings(settings: Partial<ObsidianLiveSyncSettings>): boolean {
return (
hasManagedP2PIceServerSource(settings) ||
hasManagedP2PTurnConfiguration(settings) ||
Object.values(settings.remoteConfigurations ?? {}).some(({ uri }) => {
if (!uri.startsWith("sls+p2p://")) return false;
const queryStart = uri.indexOf("?");
return queryStart >= 0 && new URLSearchParams(uri.slice(queryStart + 1).split("#", 1)[0]).has("source");
return (
queryStart >= 0 && new URLSearchParams(uri.slice(queryStart + 1).split("#", 1)[0]).has("managedType")
);
})
);
}
/** Reports retain the selected source label, but no opaque source configuration. */
export function redactTurnSourceForReport(settings: Partial<ObsidianLiveSyncSettings>): void {
if (settings.P2P_iceServerSource !== undefined) {
settings.P2P_iceServerSource = {
version: 1,
id:
iceServerSourceDefinitions.find((source) => source.id === settings.P2P_iceServerSource?.id)?.id ??
"redacted",
configuration: { redacted: true },
};
/** Reports retain a recognised provider label and omit issued credentials. */
export function redactTurnSettingsForReport(settings: Partial<ObsidianLiveSyncSettings>): void {
if (settings.P2P_managedType) {
settings.P2P_managedType =
settings.P2P_managedType === CLOUDFLARE_TURN_TYPE ? CLOUDFLARE_TURN_TYPE : "redacted";
}
if (settings.P2P_managedId !== undefined) settings.P2P_managedId = "redacted";
if (settings.P2P_managedToken !== undefined) settings.P2P_managedToken = "redacted";
delete settings.P2P_iceServers;
delete settings.P2P_iceServersExpiresAt;
}
/** Managed connection profiles are shared through Setup URIs and QR codes. */
export function omitManagedTurnProfilesFromMarkdown(settings: Partial<ObsidianLiveSyncSettings>): void {
delete settings.P2P_iceServers;
delete settings.P2P_iceServersExpiresAt;
if (!hasManagedTurnSettings(settings)) return;
delete settings.P2P_iceServerSource;
delete settings.P2P_managedType;
delete settings.P2P_managedId;
delete settings.P2P_managedToken;
delete settings.remoteConfigurations;
delete settings.activeConfigurationId;
delete settings.P2P_ActiveRemoteConfigurationId;
}
/** An omitted profile group leaves this device's existing connection selection intact. */
/** Preserve the complete connection when Markdown omits its profile group. */
export function preserveManagedTurnProfilesOnMarkdownImport(
incoming: Partial<ObsidianLiveSyncSettings>,
current: ObsidianLiveSyncSettings,
@@ -48,12 +53,11 @@ export function preserveManagedTurnProfilesOnMarkdownImport(
if (
!hasManagedTurnSettings(current) ||
incoming.remoteConfigurations !== undefined ||
incoming.P2P_iceServerSource !== undefined
) {
incoming.P2P_managedType !== undefined
)
return;
}
merged.remoteConfigurations = structuredClone(current.remoteConfigurations);
merged.activeConfigurationId = current.activeConfigurationId;
merged.P2P_ActiveRemoteConfigurationId = current.P2P_ActiveRemoteConfigurationId;
merged.P2P_iceServerSource = structuredClone(current.P2P_iceServerSource);
Object.assign(merged, pickP2PSyncSettings(current));
}
+84 -16
View File
@@ -1,26 +1,55 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
DEFAULT_SETTINGS,
REMOTE_P2P,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
SettingService,
type SettingServiceDependencies,
} from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import {
hasManagedTurnSettings,
omitManagedTurnProfilesFromMarkdown,
preserveManagedTurnProfilesOnMarkdownImport,
redactTurnSourceForReport,
redactTurnSettingsForReport,
} from "./turnSettingsPrivacy";
class MemorySettingService extends SettingService {
readonly items = new Map<string, string>();
saved?: ObsidianLiveSyncSettings;
protected setItem(key: string, value: string) {
this.items.set(key, value);
}
protected getItem(key: string) {
return this.items.get(key) ?? "";
}
protected deleteItem(key: string) {
this.items.delete(key);
}
protected saveData(settings: ObsidianLiveSyncSettings) {
this.saved = structuredClone(settings);
return Promise.resolve();
}
protected loadData() {
return Promise.resolve(this.saved);
}
}
function configuredSettings() {
return {
...DEFAULT_SETTINGS,
P2P_iceServerSource: {
version: 1,
id: "cloudflare",
configuration: { turnKeyId: "private-key-id", apiToken: "private-token" },
},
P2P_managedType: "CF",
P2P_managedId: "private-key-id",
P2P_managedToken: "private-token",
remoteConfigurations: {
managed: {
id: "managed",
name: "Managed TURN",
isEncrypted: false,
uri: "sls+p2p://room?source=private-token",
uri: "sls+p2p://room?managedType=CF&managedId=private-key-id&token=private-token",
},
},
activeConfigurationId: "central",
@@ -29,17 +58,56 @@ function configuredSettings() {
}
describe("managed TURN settings privacy", () => {
it("redacts all opaque source fields, including unknown integrations", () => {
it("preserves the active managed room through Markdown import, save, and reload", async () => {
const current = {
...configuredSettings(),
remoteType: REMOTE_P2P,
activeConfigurationId: "managed",
P2P_roomID: "local-room",
P2P_relays: "wss://local-relay.example.test",
P2P_passphrase: "local-passphrase",
};
const originalURI = ConnectionStringParser.serialize({ type: "p2p", settings: current });
current.remoteConfigurations.managed.uri = originalURI;
const service = new MemorySettingService(new ServiceContext(), {
APIService: {
getSystemVaultName: () => "test-vault",
getAppID: () => "test-app",
addLog: () => undefined,
confirm: { askString: async () => "" },
} as unknown as SettingServiceDependencies["APIService"],
});
service.settings = structuredClone(current);
const incoming: Partial<ObsidianLiveSyncSettings> = {
P2P_roomID: "imported-room",
P2P_relays: "wss://imported-relay.example.test",
P2P_passphrase: "imported-passphrase",
};
const merged = { ...structuredClone(DEFAULT_SETTINGS), ...incoming };
preserveManagedTurnProfilesOnMarkdownImport(incoming, current, merged);
await service.applyExternalSettings(merged, true);
const saved = service.saved!.remoteConfigurations.managed;
const uri = saved.isEncrypted ? await service.decryptConfigurationItem(saved.uri, "*") : saved.uri;
expect(uri).toBe(originalURI);
expect(service.settings.P2P_roomID).toBe("local-room");
await service.loadSettings();
expect(service.settings.P2P_roomID).toBe("local-room");
});
it("redacts provider fields and issued credentials, including unknown integrations", () => {
const settings = configuredSettings();
settings.P2P_iceServerSource.id = "private-token";
redactTurnSourceForReport(settings);
expect(JSON.stringify(settings.P2P_iceServerSource)).not.toMatch(/private-token|private-key-id/);
expect(settings.P2P_iceServerSource.configuration).toEqual({ redacted: true });
settings.P2P_managedType = "private-token";
redactTurnSettingsForReport(settings);
expect([settings.P2P_managedType, settings.P2P_managedId, settings.P2P_managedToken]).toEqual([
"redacted",
"redacted",
"redacted",
]);
});
it("omits the whole managed profile group from Markdown, including inactive sources", () => {
const settings = configuredSettings();
settings.P2P_iceServerSource.id = "manual";
settings.P2P_managedType = "";
expect(hasManagedTurnSettings(settings)).toBe(true);
omitManagedTurnProfilesFromMarkdown(settings);
expect(JSON.stringify(settings)).not.toMatch(/private-token|private-key-id|sls\+p2p/);
@@ -52,12 +120,12 @@ describe("managed TURN settings privacy", () => {
const current = configuredSettings();
const incoming = { ...DEFAULT_SETTINGS };
delete (incoming as Partial<typeof incoming>).remoteConfigurations;
delete (incoming as Partial<typeof incoming>).P2P_iceServerSource;
delete (incoming as Partial<typeof incoming>).P2P_managedType;
const merged = { ...DEFAULT_SETTINGS, ...incoming };
preserveManagedTurnProfilesOnMarkdownImport(incoming, current, merged);
expect(merged.remoteConfigurations).toEqual(current.remoteConfigurations);
expect(merged.remoteConfigurations).not.toBe(current.remoteConfigurations);
expect(merged.P2P_iceServerSource).toEqual(current.P2P_iceServerSource);
expect(merged.P2P_managedToken).toEqual(current.P2P_managedToken);
expect(merged.activeConfigurationId).toBe("central");
expect(merged.P2P_ActiveRemoteConfigurationId).toBe("managed");
});