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
+2
View File
@@ -189,6 +189,8 @@ steps required to add a built-in provider.
Commonlib owns one stable `LiveSyncP2PService`, its `P2PRoomSessionOwner`, and the replaceable Trystero room session. Host commands, event handlers, and views consume the focused transport, connection-probe admission, directory, peer-admission, transfer, change-relay, configuration, and diagnostic views returned by the service feature. They must not retain the deprecated compatibility Replicator as an ordinary service locator, close Trystero-owned raw peers, or install another Trystero transport generation at the application root. The exact implemented ownership and shutdown boundaries are recorded in Commonlib's [P2P transport lifecycle](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/p2p-transport-lifecycle.md) design document.
The proposed [TURN credential sources design](docs/design_docs/renewable_turn_credentials.md) covers credential expiry in the existing room reuse decision, replication continuation after room replacement, persisted and shared provider tokens, report redaction, and optional integrations on the device. It records the Commonlib work and compatibility boundaries before implementation.
### Conflict Merge Policy
Markdown conflict auto-merge should behave like a conservative three-way merge. The guiding rule is to merge changes when they touch non-overlapping regions, and to keep a manual conflict when the edits overlap semantically.
@@ -60,7 +60,15 @@ The first settings revision retains the existing storage and dialogue contract o
A future interface may present the existing comma-separated value as ordered `turn:` and `turns:` URL rows without changing its serialised representation. A structured list of multiple credential profiles is deferred until a provider or self-hosted use case requires different credentials in the same P2P profile.
Static long-term credentials are the supported first stage. Managed providers may return short-lived credentials, but LiveSync must not store a provider API token or a Coturn shared authentication secret. A future managed-credential design needs a separately trusted HTTPS endpoint, expiry handling, refresh behaviour, failure reporting, and a clear Setup URI policy. It is not represented as another static password field.
Static long-term credentials are the supported first stage. Managed credentials use an optional source implementation on the device, behind a service-independent acquisition contract. Service-specific requests and settings belong under `src/integrations/`; Commonlib owns acquisition coordination and the P2P lifecycle. A separately operated HTTPS credential endpoint is an optional future source, not a prerequisite.
A user-supplied provider API token is persisted as a sensitive P2P profile setting and included in encrypted Setup URI sharing, so that participating devices can use the same configuration without repeated token entry. Optional configuration encryption must cover every saved copy. Reports and logs redact the complete provider configuration and issued credentials, including inactive profiles and settings projections. Coturn's server-side shared authentication secret remains outside client settings.
Issued short-lived TURN credentials and their expiry remain in memory. The existing room reuse decision checks both the effective connection settings and credential validity. When reconciliation finds expired credentials, it uses the normal room retirement and replacement path with newly acquired credentials. Replacement may cancel an in-progress transfer; the next replication attempt uses stored checkpoints and revision comparison to retain received progress. Whether that next attempt starts automatically follows the existing synchronisation policy.
Time passing alone does not trigger acquisition or disconnection. This design adds no renewal timer, per-peer acquisition hook, configuration update on raw peers, or credential-driven ICE restart. Internal peer reconnection within an unchanged room does not guarantee fresh issuance. Acquisition failure is reported without changing the selected source or route policy. See [TURN credential sources](../design_docs/renewable_turn_credentials.md) for the proposed contract, persistence and sharing formats, room replacement, and verified replication continuation behaviour.
When managed sources are introduced, relay-only validation accepts a valid managed TURN source configuration as well as the existing manual URL list. Failure to acquire usable TURN entries keeps relay-only mode selected and reports the connection failure; it does not restore `Automatic` silently.
### TURN allocation check and route diagnostics
@@ -0,0 +1,508 @@
---
date: 2026-09-15
commonlib-version: "0.1.25-dev.turn-credentials.3"
self-hosted-livesync-version: "1.0.28"
status: unreleased
---
# TURN credential sources
## Purpose and decisions
This developer design addresses [Issue #1182](https://github.com/vrtmrz/obsidian-livesync/issues/1182)
through a service-independent interface for acquiring TURN credentials.
The [P2P transport compatibility ADR](../adr/2026_08_p2p_transport_compatibility.md)
records the accepted policy. The contract, lifecycle, settings, and host
integration are implemented locally. Real provider issuance, relay-only
Obsidian synchronisation, and synchronisation after explicit reconnection have
been verified. Expiry-driven TURN reconnection remains release validation work.
The design uses these decisions:
- Acquire credentials on the device through an optional service integration.
- Persist the user-supplied provider API token with the P2P profile and include
it in encrypted Setup URI sharing for additional devices.
- Redact provider configuration and issued credentials from reports and logs.
- Keep issued short-lived credentials in memory only.
- Keep a local expiry alongside issued credentials and check it in the
existing room reuse decision.
- When that decision finds expired credentials, acquire a new configuration
and use the existing room replacement lifecycle. Replacement may cancel
an in-progress transfer; the next replication attempt reuses stored progress.
- Check expiry when the room lifecycle is reconciled. Add no renewal timer,
per-peer acquisition hook, `setConfiguration()`, or credential-driven ICE
restart.
Manual TURN configuration remains supported without a provider account.
Cloudflare is the first optional integration. A separate credential endpoint,
a general authentication framework, runtime extension loading, and migration
of existing service integrations are outside the first delivery.
## Ownership and composition
An **ICE server source**, represented by `IceServerSource`, supplies ICE server
URLs, access credentials, and their expiry. This is developer vocabulary for
the acquisition contract; it is separate from a Replicator provider.
| Component | Responsibility | Owner |
| --- | --- | --- |
| Source contract | Acquisition result, validation, and safe failure categories | Commonlib |
| Credential state and room reuse | Memory cache, expiry check, acquisition, cancellation, and room replacement | Commonlib `P2PRoomSessionOwner` |
| Physical peer creation | Use the configuration supplied when joining the room | Existing Trystero implementation |
| Source catalogue and settings | Explicit source selection and host dependencies | LiveSync |
| Cloudflare source | Provider request, response conversion, and configuration validation | LiveSync `src/integrations/cloudflare/` |
Implementation placement:
```text
Commonlib
P2P source contract and private credential cache
Expiry check in the existing room owner and session construction
LiveSync
src/integrations/iceServerSources.ts
src/integrations/cloudflare/iceServerSource.ts
src/integrations/cloudflare/settings.ts
src/serviceFeatures/useIceServerSources.ts
```
`integrations/` groups code which connects external services to the common
contract. It does not imply a hosted project service or a public extension
marketplace. The service feature composes a closed catalogue of source
factories with explicit dependencies, following
[Service feature and legacy Module boundaries](service_feature_and_legacy_module_boundaries.md).
An integration receives neither `LiveSyncBaseCore` nor ownership of replication.
Supply the catalogue through an optional composition argument to
`useP2PReplicatorFeature`, preserving its manual-only default for existing
Commonlib consumers. Factories validate settings without network access;
acquisition runs only when requested by the P2P owner. Unsupported sources
produce an explicit configuration error.
```mermaid
flowchart LR
R["Existing room lifecycle reconciliation"] --> D{"Same binding and valid credentials?"}
D -->|"Yes"| K["Keep current room"]
D -->|"No"| C["Retire current room, if present"]
C --> A["Reuse valid cached credentials or acquire"]
A --> O["Open room with resolved ICE configuration"]
```
## Settings and dependencies
Present a `TURN configuration` choice with `Manual` and `Cloudflare`.
The catalogue supplies each integration's label and fields; the common P2P
engine does not branch on a service name.
| Input | Manual | Cloudflare |
| --- | --- | --- |
| TURN server URLs | Existing field | Supplied by the API |
| TURN username and credential | Existing fields | Issued in memory |
| TURN Key ID | Unused | Required and persisted |
| TURN Key API Token | Unused | Required, masked in the dialogue, and persisted |
The first Cloudflare implementation requests a 24-hour lifetime internally.
It needs no account ID, email address, custom endpoint URL, or renewal interval
setting. This lifetime is a design default, not a provider default.
Dependencies are an injected HTTP operation, a clock, cancellation/deadline
handling, and the existing settings and P2P lifecycle services. No Cloudflare
SDK, credential broker, or new operating-system secret-store dependency is
required.
Retain `P2P_turnServers`, `P2P_turnUsername`, and `P2P_turnCredential` for manual
configuration. An absent source selection means manual. Add a versioned P2P
profile descriptor, `P2P_iceServerSource`:
```json
{
"version": 1,
"id": "cloudflare",
"configuration": {
"turnKeyId": "user-supplied-key-id",
"apiToken": "user-supplied-turn-key-api-token"
}
}
```
Commonlib owns the JSON envelope; each source owns validation of its
configuration. Unsupported identifiers and versions remain preserved in
storage and produce an explicit unsupported result when selected. Loading an
inactive profile performs no acquisition.
The selected source configuration, including token changes, participates in
the effective P2P configuration identity. Issued credentials and their expiry
are separate runtime state. Room reuse requires both a matching identity and
usable credentials. Under managed selection, unused manual credentials do
not affect that identity; manual selection preserves the existing projection.
Keep the identity opaque and absent from diagnostics. Apply source changes
and expired runtime credentials through the existing room replacement policy.
## Persistence, sharing, and redaction
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.
| Destination | Provider API token | Issued TURN username and credential |
| --- | --- | --- |
| Saved P2P profile | Included | Omitted |
| Encrypted Setup URI | Included with the source and Key ID | Omitted |
| Runtime room configuration | Available only to the source | Cached in memory and passed to WebRTC |
| General report or diagnostic log | Redacted | Redacted |
Encrypted Setup URI sharing is the complete sharing route for managed
profiles. Preserve the independent main-remote and P2P selections and the
receiving device's own peer name. Raw profile and unencrypted QR copy actions
should offer encrypted Setup URI sharing when their output includes a managed
source, including one in an inactive profile. Never substitute a temporary
TURN password or silently export a profile missing its API token.
Markdown settings export must not leak tokens through either the top-level
source or a profile URI. For this first delivery, omit the profile collection,
its selections, and the source projection together when managed profiles are
present, and explain that connection sharing uses the encrypted Setup URI.
Importing Markdown without that group preserves the local profiles and
selections rather than replacing them with a filtered collection.
Reports expose only safe source labels and acquisition state. Redact the
entire opaque source configuration, including unknown source configurations,
and every stored or projected copy. Preserve the existing scheme-only
redaction of profile URIs in `src/common/reportTool.ts`. Do not log request
headers, raw API bodies, source identity values, or HTTP errors which embed
credentials. Use one redaction policy across report and diagnostic paths;
cover inactive profiles and encoded values in tests.
## Acquisition contract
Commonlib exports the acquisition contract from `/p2p`:
```typescript
type IceServerConfiguration = {
iceServers: readonly RTCIceServer[];
expiresAt: number | null;
};
declare class IceServerSourceError extends Error {
constructor(
code: "configuration" | "authentication" | "unavailable" | "invalid-response",
message: string,
retryable: boolean
);
}
interface IceServerSource {
acquire(signal: AbortSignal): Promise<IceServerConfiguration>;
}
```
`expiresAt` is a local Unix timestamp in milliseconds. `null` represents
non-expiring manual configuration; managed results require a finite expiry.
Sources throw a typed, safe failure or propagate cancellation. The room owner
calls the same operation when it needs an initial or replacement credential
set. A source does not save settings, schedule renewal, mutate peers, or
start replication.
Validate supported `stun:`, `stuns:`, `turn:`, and `turns:` URLs, complete TURN
credentials, bounded response size and entry count, and enough remaining
lifetime for connection establishment. A managed TURN source must return at
least one usable TURN entry. Copy the validated result before handing it to
WebRTC; unknown fields never become arbitrary `RTCConfiguration` options.
Preserve ordinary STUN behaviour and the selected connection-path policy.
### Cloudflare request
The source calls the fixed provider API:
```http
POST https://rtc.live.cloudflare.com/v1/turn/keys/{TURN_KEY_ID}/credentials/generate-ice-servers
Authorization: Bearer {TURN_KEY_API_TOKEN}
Content-Type: application/json
{"ttl":86400}
```
Cloudflare returns an `iceServers` array. Its documented maximum lifetime is
48 hours, and the returned ICE server structure has no TTL. Derive the local
expiry from the requested TTL and the time before the request started,
allowing for request duration and a connection-establishment margin. Reject a
response which has already become too old. See
[credential generation](https://developers.cloudflare.com/realtime/turn/generate-credentials/)
and [the TURN FAQ](https://developers.cloudflare.com/realtime/turn/faq/).
Only the Key ID, API token, and requested lifetime go to the provider. The
source has no need for a Vault passphrase, Group ID, peer name, or file data.
Use a TURN Key API Token, not an account-wide API key. Cloudflare documents a
server-side secret model; this design explicitly permits users to place and
share their own issuance token on their participating devices. Whoever
receives that token can issue credentials under its authority.
All maintained hosts inject `API.webCompatFetch`, using standard fetch
cancellation and redirect controls. The source refuses redirects, omits cookies,
requests `no-store`, and applies a 15-second deadline. It bounds the response to
32 KiB, 16 ICE entries, and 32 URLs. Commonlib independently validates the
result and requires at least 30 seconds of remaining lifetime before use.
A read-only CORS preflight on 15 September 2026 returned HTTP 204 and allowed
POST, `Authorization`, and `Content-Type` from the requested origin. This
establishes preflight support, not successful authenticated issuance. Obsidian's
`nativeFetch` adapter is not used here because its `requestUrl` path does not
forward all required fetch controls. Provider HTTP behaviour is covered by
fixtures; operator-owned credentials are still required for real issuance and
TURN allocation validation.
## Room reuse and credential expiry
### Runtime state and decision
Keep one private cached result for the effective source configuration in the
P2P room owner. It contains the validated ICE servers, `expiresAt`, and the
source identity which produced them. Reuse it while that source still matches
and its remaining lifetime is sufficient. Clear it on source change, explicit
disconnect, suspension, or owner disposal. Neither the credentials nor the
expiry becomes a persisted setting.
`expiresAt` is derived from issuance time and the requested TTL. A fixed TTL
value alone cannot identify whether an earlier issuance has expired. Keep the
expiry check separate from the stable settings signature rather than making
wall-clock time an ordinary configuration field.
The existing `reconcileTransport()` reuse decision becomes conceptually:
```typescript
const reusable =
current?.host.isServing &&
bindingsMatch(activeBinding, desiredBinding) &&
credentialsRemainUsable(activeCredentials, now);
```
Manual configuration has no managed expiry and preserves the existing
behaviour. For a managed source, a missing or expired result makes the room
ineligible for reuse even if the saved settings have not changed.
When reuse is unavailable, use the existing lifecycle queue:
1. Retire the current session, if present. Its cancellation and settlement
path also handles any in-progress transfers.
2. Resolve valid cached credentials for the desired source, or await a new
`acquire()` result. Serialised reconciliation shares this work rather than
issuing a request for each physical peer.
3. Construct the replacement session with a temporary, resolved ICE
configuration. Keep that configuration separate from persisted manual
fields and the settings projection used for policy changes.
4. Before publishing the session, recheck the source identity, expiry,
enabled state, and room demand. Discard obsolete results and candidates.
Acquisition and room opening have bounded deadlines. A result which expires
before publication is unusable. Each reconciliation makes one acquisition
attempt; a later explicit retry or existing reconciliation can try again. A credential test uses its own result and does not replace
the active room's cache.
### When the check runs
Use existing reconciliation opportunities, including explicit connection,
changes to room demand, and applicable settings/lifecycle events. Time passing
alone does not run reconciliation or close a room. If reconciliation runs
after expiry, ordinary replacement may interrupt a transfer; no additional
idle wait or transfer-preservation mechanism is required.
Not every operation passes this decision. A transfer admitted directly by an
existing session, a signalling WebSocket reconnect, and Trystero's internal
physical-peer reconnection can proceed without owner reconciliation. This
scope checks credential validity during room reconciliation and acquires a
new set when needed. Individual physical connection attempts use the room's
existing configuration.
A room which remains open beyond expiry may require an explicit reconnect
before new TURN-dependent peers can connect.
### Existing transport boundary
The inspected baseline is Commonlib `0.1.24` and Trystero `0.25.3`, as pinned
in the LiveSync lockfile:
| Package boundary | Relevant behaviour |
| --- | --- |
| Commonlib `P2PRoomSessionOwner.reconcileTransport()` | Reuses an equivalent serving room; otherwise retires it and constructs another session. |
| Commonlib `P2PRoomSession.retire()` | Rejects new work, cancels current finite operations, waits for settlement, and disposes the room. |
| Commonlib `TrysteroReplicatorP2PServer.start()` | Supplies resolved options to Trystero when joining the room. |
| Trystero `dist/strategy.mjs` and `dist/offer-pool.mjs` | The final room leave destroys the outgoing offer pool; a later join can use new options. |
| Trystero `dist/shared-peer.mjs` | Live physical peers may survive logical room leave/rejoin under Trystero ownership. |
Use the normal retire-before-open path. LiveSync does not close raw peers or
create another transport generation. The design requires no Trystero peer
factory extension, eager-pool change, or existing-peer configuration update.
Verify fresh TURN allocation after normal room replacement in the maintained
host topology; a still-connected shared peer can remain usable and is not
proof that a fresh allocation used the new credentials. This assumes one
active P2P room per host; pool replacement while another room remains open
needs separate validation.
### Replication after interruption
Commonlib `0.1.24` uses `replicateShim()` for P2P transfer. Its checkpoint is
stored in database-local documents, using the source and destination database
names and a source-side marker. The Trystero peer ID is not the checkpoint
identity. Rejoining the same databases with a new peer ID therefore retains
replication progress.
For each batch, the shim reads changes, compares destination revisions with
`revsDiff`, fetches missing revisions, writes them with `new_edits: false`,
and invokes the processing callback before advancing the checkpoint. Room
retirement does not delete the database documents or replication checkpoints.
Consequently, the next replication attempt starts at the last committed
checkpoint. If interruption or a lost response leaves writes beyond that
checkpoint, it may scan that batch again; revision comparison avoids fetching
already stored revisions again. Missing or incomplete document revisions are
retried. This preserves received Metadata and Chunks, but does not resume a
partially received network message at its last byte. Normal P2P calls use
`rewind: false`; database replacement, removed checkpoint state, or an explicit
rewind can require an earlier scan.
Starting that next attempt follows existing synchronisation policy. An
unfinished AutoSync baseline remains eligible when an accepted matching peer
is advertised again: `P2PAutomationCoordinator` only records completed
baselines. A cancelled manual transfer does not automatically restart merely
because the room reconnects; the next requested synchronisation uses the
same stored progress. This feature adds no universal transfer retry loop and
does not report a cancelled attempt as successful.
A focused check executed the pinned `ReplicatorShim.js` with in-memory
database boundaries and confirmed both cancellation after a committed batch
and loss of completion after writes but before the checkpoint. Both subsequent
attempts fetched only missing revisions. The pinned automation coordinator
also allowed another attempt after a cancelled baseline. These checks verify
the algorithms; they do not establish real WebRTC reconnection or file
reflection behaviour, which remains part of implementation validation.
### Failure and cancellation
Acquisition failure leaves the attempted room opening unavailable and reports
a safe, actionable state. Do not fall back to saved manual credentials,
choose another provider, or relax relay-only mode. Authentication and
configuration errors wait for correction or an explicit retry. Transient
failures are marked retryable for the existing lifecycle or an explicit retry;
this source adds no automatic acquisition or reconnect loop.
Explicit disconnect, source changes, and application suspension invalidate
pending acquisition. A late HTTP result cannot publish a room or restore an
obsolete source. Cancellation must take effect while room opening awaits
acquisition rather than waiting behind it in the lifecycle queue. The owner
rechecks current demand and configuration before exposing a replacement.
## Compatibility and verification
### Stored settings and sharing formats
Update Commonlib's P2P setting type, `pickP2PSyncSettings`, connection-string
parser, Setup URI processing, and settings encryption together. Update the
LiveSync Setup dialogue, import handler, profile export, Markdown settings,
and report paths. Existing fixed-field serialisers would otherwise discard
the source. Generated credentials never populate the manual fields.
Managed profile strings need a distinguishable format,
`sls+p2p-v2://`. Commonlib `0.1.24` rejects that scheme, whereas it silently
drops unknown fields in ordinary `sls+p2p://` strings. Manual profiles retain
their current format. Validate the source before activation; unknown sources
must not become manual connections.
Full encrypted Setup URIs also need a distinguishable outer format,
`obsidian://setuplivesync-v2?settings=`, and a versioned encrypted
envelope when managed profiles are included. The old import path decrypts and
merges arbitrary JSON, so a nested profile version alone is insufficient.
Validate the new envelope before applying settings in every maintained host.
Apply stored settings schema checks on load and import, including downgrades;
older clients must not activate a managed profile after dropping its source.
Document any minimum-client and downgrade requirements with the implementation.
For a selected managed source, save the complete P2P connection in its
versioned profile and disable the persisted legacy P2P projection: clear its
Group ID and passphrase, and save `P2P_Enabled` and `P2P_AutoStart` as false.
A compatible client restores those runtime values from the selected profile.
This prevents an older client which rejects the profile URI from connecting
through leftover manual fields. Source-only settings without a configured
room can remain disabled until setup is complete. The live settings and
setting-saved notifications retain their usable runtime values. A selected
manual profile retains its established persisted representation, even when
another saved profile has a managed source.
The P2P data protocol and Group ID remain unchanged. A peer using manually
configured TURN can communicate with one using issued credentials; validate
that interoperability without requiring both peers to use the same issuer.
### Real-provider verification
On 15 September 2026, the local LiveSync build with Commonlib
`0.1.25-dev.turn-credentials.3` passed a real Cloudflare TURN check in two
isolated Obsidian 1.12.7 instances on one Linux host. Both instances used the
Cloudflare source and `P2P_connectionPath: "relay"`, with a local Nostr relay
used only for signalling.
- The source received HTTP 201 responses and acquired credentials with a
requested 24-hour lifetime. The Obsidian instances also received successful
issuance responses through their own HTTP integration.
- Both endpoints reported selected local and remote candidates of type
`relay`, using UDP, before transferring a note. The receiving Vault contained
the expected note content after replication completed.
- Explicitly disconnecting one instance removed its peer advertisement from
the other. Reconnecting issued credentials again and established a new
relay-only connection. A second note then travelled in the reverse direction
and appeared with the expected content in the receiving Vault.
This check covers initial provider issuance, real relayed replication, and
credential reacquisition after an explicit disconnect. It does not establish
natural TTL expiry, interruption within a replication batch, mobile operating
system behaviour, mixed manual/managed peers, or connectivity between different
networks. Those cases retain their separate validation requirements. The
results contain no provider token, TURN username, or TURN credential.
### Acceptance criteria for implementation
- Manual configuration, default STUN, and existing Setup URIs retain their
behaviour. Unsupported managed sources fail explicitly.
- Provider tokens survive restart, profile selection, optional configuration
encryption, and encrypted Setup URI sharing. Reports and logs reveal no
tokens or issued credentials, including inactive and encoded copies.
- Issued credentials never enter persisted settings, exports, or reports.
- Equivalent settings and valid credentials reuse the room. Expired
credentials cause the next owner reconciliation to acquire and replace
through the existing lifecycle; manual settings retain their behaviour.
- Concurrent reconciliation does not duplicate acquisition. Late responses
after disconnect, source change, or suspension cannot publish a room.
Expiry tests cover delayed responses and clock changes.
- Time passing alone triggers no acquisition or replacement. There is no
per-peer acquisition hook, `setConfiguration()`, or credential-driven ICE
restart.
- Replacement during a batch settles the old attempt and preserves stored
documents and checkpoints. The next attempt transfers missing revisions;
test interrupted AutoSync and explicit manual retry separately.
- Safe failures cover authentication, rate limits, network errors, timeouts,
and malformed responses without an automatic source or route-policy change.
- Real relay-only connections verify initial establishment and room
replacement after expiry, including mixed manual/managed peers and both
initiator roles. A selected relayed candidate pair is required evidence;
direct traffic alone does not validate TURN use.
- Real Obsidian checks cover HTTP behaviour, desktop/mobile lifecycle,
persistence/sharing, and a file round trip after reconnection. Validate
supported CLI/browser hosts before enabling their direct integration.
The existing Setup connection check remains a signalling check. Credential
issuance, a disposable TURN allocation check, actual peer data transfer, and
LiveSync file synchronisation establish different facts. Tests and status
must identify which boundary they verify.
Implement the Commonlib contract, settings, runtime expiry, and existing room
replacement integration in its own repository. Validate the packed Commonlib
artefact before updating LiveSync's exact dependency and composing the
Cloudflare source. Use deterministic provider fixtures and an open-source
Coturn test service for repeatable
coverage; verify the real provider path with operator-owned test credentials.
Run Commonlib checks, LiveSync `npm run check`, unit tests, builds, and focused
consumer tests for the implementation. Deterministic source, lifecycle, persistence, sharing, and redaction tests
cover the implemented boundaries. Real provider allocation and host
reconnection evidence must be recorded separately before release.
+38 -4
View File
@@ -18,7 +18,7 @@ flowchart LR
The signalling relay and TURN server have different roles:
- The **signalling relay** is required for peer discovery and connection negotiation. LiveSync uses Nostr-compatible WebSocket relays for this role. The relay does not store or transfer Vault contents.
- A **TURN server** is an optional fallback. WebRTC uses it to relay the encrypted peer connection only when the devices cannot establish a direct path through their networks.
- A **TURN server** is an optional fallback. WebRTC uses it to relay the encrypted peer connection when the devices cannot establish a direct path through their networks, or whenever **TURN relay only** is selected.
## The project's public signalling relay
@@ -40,16 +40,50 @@ Both settings contain server addresses, but they are not interchangeable.
| Setting | Required | Carries Vault contents | Purpose |
| --- | --- | --- | --- |
| **Signalling relay URLs** | Yes | No | Finds peers and exchanges the information needed to establish WebRTC connections. |
| **TURN server URLs** | Only when direct WebRTC connectivity fails | Encrypted WebRTC traffic | Relays traffic between peers when NAT or firewall rules prevent a direct path. |
| **TURN server URLs** | When direct WebRTC connectivity fails or **TURN relay only** is selected | Encrypted WebRTC traffic | Relays traffic between peers when NAT or firewall rules prevent a direct path. |
A TURN provider cannot read LiveSync's encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust. The project does not operate an official TURN service.
WebRTC encrypts data between the devices, including when it passes through TURN. The TURN provider cannot read the transferred data, but it can observe network addresses and traffic volume. This transport encryption also applies when LiveSync's optional database encryption is disabled. The project does not operate an official TURN service.
## TURN credentials
In **TURN configuration**, select **Manual** to enter your own TURN server URLs,
username, and credential, or select **Cloudflare** to enter a **TURN Key ID** and
**TURN Key API Token**. Cloudflare is optional; the project does not require a
particular TURN provider or operate a credential broker. See Cloudflare's
[credential instructions](https://developers.cloudflare.com/realtime/turn/generate-credentials/)
for creating a TURN key and its API token.
The API token is saved with the P2P profile. Use an encrypted Setup URI to share
it with your other devices. Managed profiles use the versioned Setup URI format
and require a client which supports that format; update receiving devices
before importing it. Older clients leave the saved managed P2P connection
disabled. Select and save a manual TURN configuration in a compatible client
before downgrading if P2P must remain usable. Plain QR export redirects to
encrypted Setup URI sharing.
Markdown settings omit the connection profile group when it contains a managed
TURN source, including inactive profiles, and importing those omitted settings
preserves this device's existing profiles. Diagnostic reports redact the source
configuration. Optional configuration encryption also covers the saved token.
Each device requests temporary TURN credentials before opening a room when no
valid credentials are cached. Cloudflare credentials have a requested lifetime
of 24 hours and remain in memory only. Expiry is checked when LiveSync next
reconciles the room connection. If necessary, it replaces the room and obtains
new credentials. There is no periodic renewal: if a long-lived room cannot
reconnect after credentials expire, disconnect and open the connection again.
Room replacement may interrupt replication. The next synchronisation keeps
received Metadata and Chunks, resumes from its saved checkpoint, and compares
revisions to fetch missing data. An unfinished network message can be sent
again. Automatic synchronisation follows the existing peer rules; after an
interrupted manual operation, use **Replicate now** again.
## Connection compatibility profiles
`P2P Configuration` includes a separate `Connection compatibility` section. Its defaults preserve the existing transport behaviour:
- **P2P message size** defaults to **Standard**. **Reduced**, **Conservative**, and **Maximum compatibility** progressively limit outgoing P2P messages when a network path appears to drop larger WebRTC messages. This is not a Vault Chunk size or an IP MTU. Smaller values add framing and processing overhead.
- **Connection path** defaults to **Automatic**, which lets WebRTC select a viable direct or TURN-relayed path. **TURN relay only** forces the encrypted connection through TURN and is available only when the profile contains at least one valid `turn:` or `turns:` URL.
- **Connection path** defaults to **Automatic**, which lets WebRTC select a viable direct or TURN-relayed path. **TURN relay only** forces the encrypted connection through TURN and is available when the profile contains a valid manual TURN URL or a configured TURN credential source.
The sending device controls its outgoing message size. Select the same conservative preset on every device which may send across the constrained path. Existing devices do not receive the choice retrospectively merely because another device changed it.
+21 -1
View File
@@ -485,6 +485,26 @@ Setting key: P2P_AutoBroadcast
When enabled, this device notifies connected peers after a local change. The notification contains no Vault data. A receiving peer fetches the change only when it follows this device.
#### TURN configuration
Setting key: P2P_iceServerSource
Select **Manual** for the existing TURN server fields, or **Cloudflare** for a
TURN Key ID and TURN Key API Token. The API token is persisted with the profile
and included in encrypted Setup URI sharing. Issued temporary credentials are
kept in memory only. Reports redact the source configuration. See
[TURN credentials](p2p.md#turn-credentials) for sharing, expiry, and reconnect
behaviour.
#### TURN Key ID and TURN Key API Token
Setting keys: P2P_iceServerSource.configuration.turnKeyId,
P2P_iceServerSource.configuration.apiToken
These fields appear when **Cloudflare** is selected. Enter the TURN key's ID and
its dedicated API token. The token field is masked. No account ID, custom
endpoint, or renewal interval is required.
#### TURN Server URLs (comma-separated)
Setting key: P2P_turnServers
@@ -515,7 +535,7 @@ The sender controls the size of its outgoing messages. Select the same conservat
Setting key: P2P_connectionPath
**Automatic** lets WebRTC select a viable direct or TURN-relayed path and is the default. **TURN relay only** forces `iceTransportPolicy: 'relay'` and is available only when the profile contains at least one valid `turn:` or `turns:` URL. Removing the last valid TURN URL while relay-only mode is selected restores **Automatic** and displays a Notice.
**Automatic** lets WebRTC select a viable direct or TURN-relayed path and is the default. **TURN relay only** forces `iceTransportPolicy: 'relay'` and is available when the profile contains a valid manual TURN URL or a configured TURN credential source. Removing the manual TURN configuration while relay-only mode is selected restores **Automatic** and displays a Notice. A selected credential source which cannot supply credentials prevents the connection from opening; it does not change the connection path.
This choice belongs to the P2P profile and is retained in P2P connection strings and encrypted Setup URIs. Separate profiles may use the same Group ID and credentials with different compatibility choices; only the selected P2P profile is active.
+7
View File
@@ -63,6 +63,13 @@ export default defineConfig(
"@typescript-eslint/no-unnecessary-type-assertion": "warn",
},
},
{
files: ["src/integrations/**/*.ts"],
rules: {
// External-service integrations also run in Node and do not own window UI.
"obsidianmd/no-global-this": "off",
},
},
{
files: ["src/apps/**/*.{ts,js,mjs}"],
rules: {
+7
View File
@@ -99,6 +99,13 @@ export default defineConfig([
...ImportAliasRules("."),
},
},
{
files: ["src/integrations/**/*.ts"],
rules: {
// External-service integrations also run in Node and do not own window UI.
"obsidianmd/no-global-this": "off",
},
},
{
files: ["src/apps/**/*.ts"],
rules: {
+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": "0.1.24",
"@vrtmrz/livesync-commonlib": "file:.devmemo.local/packages/vrtmrz-livesync-commonlib-0.1.25-dev.turn-credentials.3.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.24",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.24.tgz",
"integrity": "sha512-gOXKo3ptEUYDkjLd5PGq2vAhOSvxOqW6xE7YWo9Y8ienglfYBz8R3eZ0I/JruvwZltH2B7Bmi41pHMjmRJQe3Q==",
"version": "0.1.25-dev.turn-credentials.3",
"resolved": "file:.devmemo.local/packages/vrtmrz-livesync-commonlib-0.1.25-dev.turn-credentials.3.tgz",
"integrity": "sha512-hngE1zlNocD8IeMgRvssX4WN7SpINOyWOp9Mzs0PGM10yzVvYtR5WcVdxWtEIbaA71DLk9SPmgmQkgu+o7Oy8g==",
"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": "0.1.24",
"@vrtmrz/livesync-commonlib": "file:.devmemo.local/packages/vrtmrz-livesync-commonlib-0.1.25-dev.turn-credentials.3.tgz",
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
@@ -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 }),
};
}
+1 -1
View File
@@ -27,7 +27,7 @@ const runtimeMocks = vi.hoisted(() => {
};
const serviceHub = {
API: { addLog },
API: { addLog, webCompatFetch: vi.fn() },
appLifecycle: { isReady, markIsReady },
control: { onLoad, onReady, onUnload },
databaseEvents: { onDatabaseInitialised },
@@ -52,6 +52,29 @@ Deno.test({
assertEquals(await page.getByPlaceholder("Enter TURN username").inputValue(), "browser-turn-user");
assertEquals(await page.getByPlaceholder("Enter TURN credential").inputValue(), "browser-turn-credential");
assertEquals(await page.getByRole("button", { name: "Connect", exact: true }).isVisible(), true);
await page.getByLabel("TURN configuration", { exact: true }).selectOption("cloudflare");
assertEquals(await saveTurn.isDisabled(), true);
await page.getByLabel("TURN Key ID", { exact: true }).fill("browser-turn-key");
const tokenField = page.getByLabel("TURN Key API Token", { exact: true });
assertEquals(await tokenField.getAttribute("type"), "password");
await tokenField.fill("browser-api-token");
await saveTurn.click();
await waitFor(async () => await saveTurn.isDisabled(), "WebPeer did not save its managed TURN profile");
assertEquals(await page.getByPlaceholder("anything-you-like").inputValue(), "browser-e2e-room");
await page.reload();
await page.getByRole("heading", { name: "Peer to Peer Replicator", exact: true }).waitFor();
assertEquals(await page.getByPlaceholder("anything-you-like").inputValue(), "browser-e2e-room");
await page.getByText("Optional TURN server settings", { exact: true }).click();
assertEquals(await page.getByLabel("TURN configuration", { exact: true }).inputValue(), "cloudflare");
assertEquals(await page.getByLabel("TURN Key ID", { exact: true }).inputValue(), "browser-turn-key");
assertEquals(
await page.getByLabel("TURN Key API Token", { exact: true }).inputValue(),
"browser-api-token"
);
await page.getByLabel("TURN configuration", { exact: true }).selectOption("manual");
assertEquals(await page.getByPlaceholder("Enter TURN username").inputValue(), "browser-turn-user");
assertEquals(await page.getByPlaceholder("Enter TURN credential").inputValue(), "browser-turn-credential");
assertNoPageFailures();
} finally {
await browser.close();