From 2e45650f22f0022fecff194d6740c5f3d49a1d36 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 6 May 2026 17:25:16 +0800 Subject: [PATCH] Allow self-hosted relays to opt into wss:// (#767) * Add relay TLS schema fields * Wire relay TLS into websocket URLs * Propagate relay TLS from pairing offers * Expose relay TLS opt-in * Mark relay TLS fallback for removal * Preserve explicit relay TLS false * Align relay TLS defaults outside config loader --- README.md | 46 +++++++++++++++++ docs/architecture.md | 1 + docs/data-model.md | 2 +- packages/app/src/app/pair-scan.tsx | 1 + .../app/src/components/pair-link-modal.tsx | 1 + packages/app/src/runtime/host-runtime.test.ts | 38 ++++++++++++++ packages/app/src/runtime/host-runtime.ts | 10 +++- .../app/src/types/host-connection.test.ts | 44 ++++++++++++++++ packages/app/src/types/host-connection.ts | 8 ++- .../src/utils/test-daemon-connection.test.ts | 31 ++++++++++++ .../app/src/utils/test-daemon-connection.ts | 2 +- .../daemon/local-daemon.supervision.test.ts | 19 +++++++ .../cli/src/commands/daemon/local-daemon.ts | 13 +++++ packages/cli/src/commands/daemon/pair.ts | 1 + packages/cli/src/commands/daemon/start.ts | 1 + packages/cli/src/commands/daemon/status.ts | 9 ++++ packages/cli/src/commands/onboard.ts | 1 + packages/cli/src/utils/client.ts | 2 +- packages/relay/src/live-relay.e2e.test.ts | 3 ++ packages/server/src/server/bootstrap.ts | 5 +- .../server/src/server/config-relay.test.ts | 50 +++++++++++++++++++ packages/server/src/server/config.ts | 12 ++++- .../server/src/server/connection-offer.ts | 2 +- packages/server/src/server/daemon-worker.ts | 3 ++ packages/server/src/server/pairing-offer.ts | 4 +- .../src/server/persisted-config.test.ts | 17 +++++++ .../server/src/server/persisted-config.ts | 1 + .../server/src/server/relay-transport.test.ts | 24 +++++++++ packages/server/src/server/relay-transport.ts | 11 ++-- .../src/shared/connection-offer.test.ts | 33 ++++++++++++ .../server/src/shared/connection-offer.ts | 1 + .../src/shared/daemon-endpoints.test.ts | 14 ++++++ .../server/src/shared/daemon-endpoints.ts | 4 ++ 33 files changed, 397 insertions(+), 17 deletions(-) create mode 100644 packages/server/src/server/config-relay.test.ts diff --git a/README.md b/README.md index 2947d9fbb..761119cc1 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,52 @@ npm run typecheck - [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go +### Self-hosted relay TLS + +Self-hosted relays use `ws://` unless TLS is opted in. For a relay behind nginx on 443, start the daemon with: + +```bash +PASEO_RELAY_ENDPOINT=127.0.0.1:8080 \ +PASEO_RELAY_PUBLIC_ENDPOINT=relay.example.com:443 \ +PASEO_RELAY_USE_TLS=true \ +paseo daemon start +``` + +Equivalent config: + +```json +{ + "daemon": { + "relay": { + "enabled": true, + "endpoint": "127.0.0.1:8080", + "publicEndpoint": "relay.example.com:443", + "useTls": true + } + } +} +``` + +Minimal nginx WebSocket proxy: + +```nginx +server { + listen 443 ssl; + server_name relay.example.com; + + ssl_certificate /etc/letsencrypt/live/relay.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/relay.example.com/privkey.pem; + + location /ws { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} +``` + ---

diff --git a/docs/architecture.md b/docs/architecture.md index 863a60cb7..28083e7a6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -95,6 +95,7 @@ Enables remote access when the daemon is behind a firewall. - Relay server is zero-knowledge — it routes encrypted bytes, cannot read content - Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`) - Pairing via QR code transfers the daemon's public key to the client +- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true` See [SECURITY.md](../SECURITY.md) for the full threat model. diff --git a/docs/data-model.md b/docs/data-model.md index e089a201d..e925abe8d 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -133,7 +133,7 @@ Single file, validated with `PersistedConfigSchema`. hostnames: true | string[], mcp: { enabled: boolean }, cors: { allowedOrigins: string[] }, - relay: { enabled: boolean, endpoint: string, publicEndpoint: string } + relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean } }, app: { baseUrl: string diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx index da5924974..7ff8db78f 100644 --- a/packages/app/src/app/pair-scan.tsx +++ b/packages/app/src/app/pair-scan.tsx @@ -178,6 +178,7 @@ export default function PairScanScreen() { id: "probe", type: "relay", relayEndpoint: normalizeHostPort(offer.relay.endpoint), + useTls: offer.relay.useTls, daemonPublicKeyB64: offer.daemonPublicKeyB64, }, { serverId: offer.serverId }, diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/components/pair-link-modal.tsx index f03f4b0c2..979c12985 100644 --- a/packages/app/src/components/pair-link-modal.tsx +++ b/packages/app/src/components/pair-link-modal.tsx @@ -137,6 +137,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM id: "probe", type: "relay", relayEndpoint: normalizeHostPort(parsedOffer.relay.endpoint), + useTls: parsedOffer.relay.useTls, daemonPublicKeyB64: parsedOffer.daemonPublicKeyB64, }, { serverId: parsedOffer.serverId }, diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 2507d2782..682c3b39b 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -198,6 +198,7 @@ function makeOffer(input?: Partial): ConnectionOffer { daemonPublicKeyB64: input?.daemonPublicKeyB64 ?? "pk_test_offer", relay: { endpoint: input?.relay?.endpoint ?? "relay.paseo.sh:443", + useTls: input?.relay?.useTls ?? false, }, }; } @@ -1616,6 +1617,43 @@ describe("HostRuntimeStore", () => { store.syncHosts([]); }); + it("stores relay TLS from a pairing offer", async () => { + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + await store.upsertConnectionFromOffer( + makeOffer({ + relay: { + endpoint: "relay.example.com:443", + useTls: true, + }, + }), + "tls relay", + ); + + const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer"); + expect(pairedHost?.connections).toEqual([ + { + id: "relay:wss:relay.example.com:443", + type: "relay", + relayEndpoint: "relay.example.com:443", + useTls: true, + daemonPublicKeyB64: "pk_test_offer", + }, + ]); + + store.syncHosts([]); + }); + it("uses the latest advertised hostname when re-pairing an existing relay host", async () => { const store = new HostRuntimeStore({ deps: { diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 351b5a1fc..b38233411 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -476,7 +476,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps { ...base, url: buildRelayWebSocketUrl({ endpoint: connection.relayEndpoint, - useTls: shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint), + useTls: connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint), serverId: host.serverId, }), e2ee: { @@ -1414,21 +1414,25 @@ export class HostRuntimeStore { async upsertRelayConnection(input: { serverId: string; relayEndpoint: string; + useTls?: boolean; daemonPublicKeyB64: string; label?: string; }): Promise { const relayEndpoint = normalizeHostPort(input.relayEndpoint); + const useTls = input.useTls ?? false; const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim(); if (!daemonPublicKeyB64) { throw new Error("daemonPublicKeyB64 is required"); } + const explicitUseTls = input.useTls !== undefined; return this.upsertHostConnection({ serverId: input.serverId, label: input.label, connection: { - id: `relay:${relayEndpoint}`, + id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`, type: "relay", relayEndpoint, + ...(explicitUseTls ? { useTls } : {}), daemonPublicKeyB64, }, }); @@ -1438,6 +1442,7 @@ export class HostRuntimeStore { return this.upsertRelayConnection({ serverId: offer.serverId, relayEndpoint: offer.relay.endpoint, + useTls: offer.relay.useTls, daemonPublicKeyB64: offer.daemonPublicKeyB64, label, }); @@ -1983,6 +1988,7 @@ export interface HostMutations { upsertRelayConnection: (input: { serverId: string; relayEndpoint: string; + useTls?: boolean; daemonPublicKeyB64: string; label?: string; }) => Promise; diff --git a/packages/app/src/types/host-connection.test.ts b/packages/app/src/types/host-connection.test.ts index a476334fe..2837def0d 100644 --- a/packages/app/src/types/host-connection.test.ts +++ b/packages/app/src/types/host-connection.test.ts @@ -27,4 +27,48 @@ describe("normalizeStoredHostProfile", () => { }); expect(profile?.connections[0]).not.toHaveProperty("password"); }); + + it("preserves legacy relay ids when TLS is absent", () => { + const profile = normalizeStoredHostProfile({ + serverId: "srv_relay", + connections: [ + { + id: "relay:relay.example.com:80", + type: "relay", + relayEndpoint: "relay.example.com:80", + daemonPublicKeyB64: "pubkey", + }, + ], + }); + + expect(profile?.connections[0]).toEqual({ + id: "relay:relay.example.com:80", + type: "relay", + relayEndpoint: "relay.example.com:80", + daemonPublicKeyB64: "pubkey", + }); + }); + + it("namespaces relay ids only when TLS is true", () => { + const profile = normalizeStoredHostProfile({ + serverId: "srv_relay", + connections: [ + { + id: "relay:relay.example.com:443", + type: "relay", + relayEndpoint: "relay.example.com:443", + useTls: true, + daemonPublicKeyB64: "pubkey", + }, + ], + }); + + expect(profile?.connections[0]).toEqual({ + id: "relay:wss:relay.example.com:443", + type: "relay", + relayEndpoint: "relay.example.com:443", + useTls: true, + daemonPublicKeyB64: "pubkey", + }); + }); }); diff --git a/packages/app/src/types/host-connection.ts b/packages/app/src/types/host-connection.ts index 9984bfefe..5b1c586cd 100644 --- a/packages/app/src/types/host-connection.ts +++ b/packages/app/src/types/host-connection.ts @@ -22,6 +22,7 @@ export interface RelayHostConnection { id: string; type: "relay"; relayEndpoint: string; + useTls?: boolean; daemonPublicKeyB64: string; } @@ -73,6 +74,7 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool if (left.type === "relay" && right.type === "relay") { return ( left.relayEndpoint === right.relayEndpoint && + left.useTls === right.useTls && left.daemonPublicKeyB64 === right.daemonPublicKeyB64 ); } @@ -243,7 +245,7 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null { if (!record) { return null; } - const type = typeof record.type === "string" ? record.type : null; + const type = record.type; if (type === "directTcp") { try { const endpoint = normalizeLoopbackToLocalhost( @@ -277,10 +279,12 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null { typeof record.daemonPublicKeyB64 === "string" ? record.daemonPublicKeyB64 : "" ).trim(); if (!daemonPublicKeyB64) return null; + const useTls = typeof record.useTls === "boolean" ? record.useTls : undefined; return { - id: `relay:${relayEndpoint}`, + id: useTls === true ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`, type: "relay", relayEndpoint, + ...(useTls !== undefined ? { useTls } : {}), daemonPublicKeyB64, }; } catch { diff --git a/packages/app/src/utils/test-daemon-connection.test.ts b/packages/app/src/utils/test-daemon-connection.test.ts index 5f495f86b..eb727d67f 100644 --- a/packages/app/src/utils/test-daemon-connection.test.ts +++ b/packages/app/src/utils/test-daemon-connection.test.ts @@ -145,6 +145,37 @@ describe("test-daemon-connection connectToDaemon", () => { expect(daemonClientMock.createdConfigs[0]?.password).toBe("shared-secret"); }); + it("uses relay TLS from the stored connection", async () => { + const mod = await import("./test-daemon-connection"); + + const tlsResult = await mod.connectToDaemon( + { + id: "relay:wss:[::1]:443", + type: "relay", + relayEndpoint: "[::1]:443", + useTls: true, + daemonPublicKeyB64: "pubkey", + }, + { serverId: "srv_probe_test" }, + ); + await tlsResult.client.close(); + + const plainResult = await mod.connectToDaemon( + { + id: "relay:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + useTls: false, + daemonPublicKeyB64: "pubkey", + }, + { serverId: "srv_probe_test" }, + ); + await plainResult.client.close(); + + expect(daemonClientMock.createdConfigs[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/); + expect(daemonClientMock.createdConfigs[1]?.url).toMatch(/^ws:\/\/relay\.paseo\.sh:443\/ws\?/); + }); + it("surfaces auth rejection as an incorrect password", async () => { const mod = await import("./test-daemon-connection"); daemonClientMock.setNextConnectFailure( diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index a7ae739ff..ed0677e27 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -110,7 +110,7 @@ export async function buildClientConfig( ...base, url: buildRelayWebSocketUrl({ endpoint: connection.relayEndpoint, - useTls: shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint), + useTls: connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint), serverId, }), e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 }, diff --git a/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts b/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts index f6da53c79..2f883a664 100644 --- a/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts +++ b/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts @@ -77,4 +77,23 @@ describe("local daemon launch supervision", () => { expectSupervisorLaunch(argv); expect(argv).toContain("--no-mcp"); }); + + test("relay TLS flag is passed to the supervised daemon", async () => { + mocks.spawnSync.mockReturnValue({ status: 0, error: undefined }); + + const { startLocalDaemonForeground } = await import("./local-daemon.js"); + const status = startLocalDaemonForeground({ + home: "/tmp/paseo-test", + relayUseTls: true, + }); + + expect(status).toBe(0); + const [, argv, options] = mocks.spawnSync.mock.calls[0] as [ + string, + string[], + { env?: NodeJS.ProcessEnv }, + ]; + expect(argv).toContain("--relay-use-tls"); + expect(options.env?.PASEO_RELAY_USE_TLS).toBe("true"); + }); }); diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index ad36e066f..ea485da6a 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -11,6 +11,7 @@ export interface DaemonStartOptions { home?: string; foreground?: boolean; relay?: boolean; + relayUseTls?: boolean; mcp?: boolean; injectMcp?: boolean; hostnames?: string; @@ -28,6 +29,9 @@ export interface LocalDaemonPidInfo { export interface LocalDaemonState { home: string; listen: string; + relayEnabled: boolean; + relayEndpoint: string; + relayUseTls: boolean; logPath: string; pidPath: string; pidInfo: LocalDaemonPidInfo | null; @@ -93,6 +97,9 @@ function buildRunnerArgs(options: DaemonStartOptions): string[] { if (options.relay === false) { args.push("--no-relay"); } + if (options.relayUseTls === true) { + args.push("--relay-use-tls"); + } if (options.mcp === false) { args.push("--no-mcp"); @@ -117,6 +124,9 @@ function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv { if (options.hostnames) { childEnv.PASEO_HOSTNAMES = options.hostnames; } + if (options.relayUseTls === true) { + childEnv.PASEO_RELAY_USE_TLS = "true"; + } return childEnv; } @@ -353,6 +363,9 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD return { home, listen, + relayEnabled: config.relayEnabled ?? true, + relayEndpoint: config.relayPublicEndpoint ?? config.relayEndpoint ?? "relay.paseo.sh:443", + relayUseTls: config.relayUseTls ?? false, logPath, pidPath, pidInfo, diff --git a/packages/cli/src/commands/daemon/pair.ts b/packages/cli/src/commands/daemon/pair.ts index e96636fdc..4f228110b 100644 --- a/packages/cli/src/commands/daemon/pair.ts +++ b/packages/cli/src/commands/daemon/pair.ts @@ -28,6 +28,7 @@ export async function runPairCommand(options: PairOptions): Promise { relayEnabled: config.relayEnabled, relayEndpoint: config.relayEndpoint, relayPublicEndpoint: config.relayPublicEndpoint, + relayUseTls: config.relayUseTls, appBaseUrl: config.appBaseUrl, includeQr: true, }); diff --git a/packages/cli/src/commands/daemon/start.ts b/packages/cli/src/commands/daemon/start.ts index 7a3e5991e..65d3e7834 100644 --- a/packages/cli/src/commands/daemon/start.ts +++ b/packages/cli/src/commands/daemon/start.ts @@ -21,6 +21,7 @@ export function startCommand(): Command { .option("--home ", "Paseo home directory (default: ~/.paseo)") .option("--foreground", "Run in foreground (don't daemonize)") .option("--no-relay", "Disable relay connection") + .option("--relay-use-tls", "Use wss:// for the relay connection and pairing offers") .option("--no-mcp", "Disable the Agent MCP HTTP endpoint") .option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents") .option( diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index aa0d05e36..987b073d1 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -18,6 +18,7 @@ interface DaemonStatus { connectedDaemon: "reachable" | "unreachable" | "not_probed"; home: string; listen: string; + relay: string; hostname: string | null; pid: number | null; startedAt: string | null; @@ -117,6 +118,7 @@ function toStatusRows(status: DaemonStatus): StatusRow[] { { key: "Connected Daemon", value: status.connectedDaemon }, { key: "Home", value: status.home }, { key: "Listen", value: status.listen }, + { key: "Relay", value: status.relay }, { key: "Hostname", value: status.hostname ?? "-" }, { key: "PID", value: status.pid === null ? "-" : String(status.pid) }, { key: "Started", value: status.startedAt ?? "-" }, @@ -312,6 +314,12 @@ async function resolveDaemonNodeLabel( return fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`; } +function formatRelayStatus(state: ReturnType): string { + if (!state.relayEnabled) return "disabled"; + const scheme = state.relayUseTls ? "wss" : "ws"; + return `${scheme}://${state.relayEndpoint}`; +} + export type StatusResult = ListResult; export async function runStatusCommand( @@ -370,6 +378,7 @@ export async function runStatusCommand( connectedDaemon, home: state.home, listen: state.listen, + relay: formatRelayStatus(state), hostname: state.pidInfo?.hostname ?? null, pid: state.pidInfo?.pid ?? null, startedAt: state.pidInfo?.startedAt ?? null, diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index 59fd2b5fb..4eb4a04f5 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -491,6 +491,7 @@ export async function runOnboard(options: OnboardOptions): Promise { relayEnabled: config.relayEnabled, relayEndpoint: config.relayEndpoint, relayPublicEndpoint: config.relayPublicEndpoint, + relayUseTls: config.relayUseTls, appBaseUrl: config.appBaseUrl, includeQr: true, }); diff --git a/packages/cli/src/utils/client.ts b/packages/cli/src/utils/client.ts index 3f724981b..9cd7e89d5 100644 --- a/packages/cli/src/utils/client.ts +++ b/packages/cli/src/utils/client.ts @@ -286,7 +286,7 @@ async function connectViaRelayOffer( endpoint: offer.relay.endpoint, serverId: offer.serverId, role: "client", - useTls: shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint), + useTls: offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint), }); const client = new DaemonClient({ diff --git a/packages/relay/src/live-relay.e2e.test.ts b/packages/relay/src/live-relay.e2e.test.ts index f5a86eb23..1526c6cd4 100644 --- a/packages/relay/src/live-relay.e2e.test.ts +++ b/packages/relay/src/live-relay.e2e.test.ts @@ -9,6 +9,9 @@ import { decrypt, } from "./crypto.js"; +// This live test uses the hosted relay's real TLS endpoint. Self-hosted relay TLS +// opt-in is covered at URL-building/integration level so the local E2E does not +// need to provision trusted certificates. const RELAY_BASE_URL = "wss://relay.paseo.sh"; async function withRetry( diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index ed39df878..c62c8d05e 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -191,6 +191,7 @@ export interface PaseoDaemonConfig { relayEnabled?: boolean; relayEndpoint?: string; relayPublicEndpoint?: string; + relayUseTls?: boolean; appBaseUrl?: string; auth?: DaemonAuthConfig; openai?: PaseoOpenAIConfig; @@ -772,6 +773,7 @@ export async function createPaseoDaemon( const relayEnabled = config.relayEnabled ?? true; const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443"; const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint; + const relayUseTls = config.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443"; const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh"; if (boundListenTarget.type === "tcp") { @@ -846,7 +848,7 @@ export async function createPaseoDaemon( const offer = await createConnectionOfferV2({ serverId, daemonPublicKeyB64: daemonKeyPair.publicKeyB64, - relay: { endpoint: relayPublicEndpoint }, + relay: { endpoint: relayPublicEndpoint, useTls: relayUseTls }, }); encodeOfferToFragmentUrl({ offer, appBaseUrl }); @@ -861,6 +863,7 @@ export async function createPaseoDaemon( return wsServer.attachExternalSocket(ws, metadata); }, relayEndpoint, + relayUseTls, serverId, daemonKeyPair: daemonKeyPair.keyPair, }); diff --git a/packages/server/src/server/config-relay.test.ts b/packages/server/src/server/config-relay.test.ts new file mode 100644 index 000000000..45cd9f358 --- /dev/null +++ b/packages/server/src/server/config-relay.test.ts @@ -0,0 +1,50 @@ +import { mkdir, mkdtemp, writeFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; + +import { loadConfig } from "./config.js"; + +const roots: string[] = []; + +async function createPaseoHome(config: unknown): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-config-relay-")); + roots.push(root); + const paseoHome = path.join(root, ".paseo"); + await mkdir(paseoHome, { recursive: true }); + await writeFile(path.join(paseoHome, "config.json"), JSON.stringify(config, null, 2)); + return paseoHome; +} + +describe("daemon relay config", () => { + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); + }); + + test("loads relay TLS from env, persisted config, and hosted relay fallback", async () => { + const persistedHome = await createPaseoHome({ + version: 1, + daemon: { + relay: { + endpoint: "relay.example.com:443", + useTls: true, + }, + }, + }); + expect(loadConfig(persistedHome, { env: {} }).relayUseTls).toBe(true); + + const envHome = await createPaseoHome({ + version: 1, + daemon: { + relay: { + endpoint: "relay.example.com:443", + useTls: false, + }, + }, + }); + expect(loadConfig(envHome, { env: { PASEO_RELAY_USE_TLS: "true" } }).relayUseTls).toBe(true); + + const hostedHome = await createPaseoHome({ version: 1, daemon: { relay: {} } }); + expect(loadConfig(hostedHome, { env: {} }).relayUseTls).toBe(true); + }); +}); diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 3bd4b52a8..af81a52bd 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -51,6 +51,7 @@ function normalizeLogEnv(value: string | undefined): string | undefined { export type CliConfigOverrides = Partial<{ listen: string; relayEnabled: boolean; + relayUseTls: boolean; mcpEnabled: boolean; mcpInjectIntoAgents: boolean; hostnames: HostnamesConfig; @@ -139,12 +140,14 @@ interface ResolveRelayInput { env: NodeJS.ProcessEnv; persisted: ReturnType; cliRelayEnabled: boolean | undefined; + cliRelayUseTls: boolean | undefined; } interface ResolvedRelay { enabled: boolean; endpoint: string; publicEndpoint: string; + useTls: boolean; } function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay { @@ -161,7 +164,12 @@ function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay { input.env.PASEO_RELAY_PUBLIC_ENDPOINT ?? input.persisted.daemon?.relay?.publicEndpoint ?? endpoint; - return { enabled, endpoint, publicEndpoint }; + const useTls = + input.cliRelayUseTls ?? + (input.env.PASEO_RELAY_USE_TLS !== undefined + ? (parseBooleanEnv(input.env.PASEO_RELAY_USE_TLS) ?? false) + : (input.persisted.daemon?.relay?.useTls ?? endpoint === DEFAULT_RELAY_ENDPOINT)); + return { enabled, endpoint, publicEndpoint, useTls }; } interface ResolvedVoiceLlm { @@ -265,6 +273,7 @@ export function loadConfig( env, persisted, cliRelayEnabled: options?.cli?.relayEnabled, + cliRelayUseTls: options?.cli?.relayUseTls, }); const { openai, speech } = resolveSpeechConfig({ @@ -293,6 +302,7 @@ export function loadConfig( relayEnabled: relay.enabled, relayEndpoint: relay.endpoint, relayPublicEndpoint: relay.publicEndpoint, + relayUseTls: relay.useTls, appBaseUrl, auth: resolveAuthConfig(env, persisted), openai, diff --git a/packages/server/src/server/connection-offer.ts b/packages/server/src/server/connection-offer.ts index 3b8b76b2a..59b7ab8b3 100644 --- a/packages/server/src/server/connection-offer.ts +++ b/packages/server/src/server/connection-offer.ts @@ -30,7 +30,7 @@ export function buildOfferEndpoints({ listenHost, port }: BuildOfferEndpointsArg export async function createConnectionOfferV2(args: { serverId: string; daemonPublicKeyB64: string; - relay: { endpoint: string }; + relay: { endpoint: string; useTls?: boolean }; }): Promise { return ConnectionOfferV2Schema.parse({ v: 2, diff --git a/packages/server/src/server/daemon-worker.ts b/packages/server/src/server/daemon-worker.ts index 2ad924bdb..eca0b368d 100644 --- a/packages/server/src/server/daemon-worker.ts +++ b/packages/server/src/server/daemon-worker.ts @@ -40,6 +40,9 @@ function applyCliFlagOverrides(config: ReturnType): void { if (process.argv.includes("--no-relay")) { config.relayEnabled = false; } + if (process.argv.includes("--relay-use-tls")) { + config.relayUseTls = true; + } if (process.argv.includes("--no-mcp")) { config.mcpEnabled = false; } diff --git a/packages/server/src/server/pairing-offer.ts b/packages/server/src/server/pairing-offer.ts index be6fdb64f..b9b0aa185 100644 --- a/packages/server/src/server/pairing-offer.ts +++ b/packages/server/src/server/pairing-offer.ts @@ -16,6 +16,7 @@ export async function generateLocalPairingOffer(args: { relayEnabled?: boolean; relayEndpoint?: string; relayPublicEndpoint?: string; + relayUseTls?: boolean; appBaseUrl?: string; includeQr?: boolean; logger?: Logger; @@ -31,13 +32,14 @@ export async function generateLocalPairingOffer(args: { const relayEndpoint = args.relayEndpoint ?? "relay.paseo.sh:443"; const relayPublicEndpoint = args.relayPublicEndpoint ?? relayEndpoint; + const relayUseTls = args.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443"; const appBaseUrl = args.appBaseUrl ?? "https://app.paseo.sh"; const serverId = getOrCreateServerId(args.paseoHome, { logger: args.logger }); const daemonKeyPair = await loadOrCreateDaemonKeyPair(args.paseoHome, args.logger); const offer = await createConnectionOfferV2({ serverId, daemonPublicKeyB64: daemonKeyPair.publicKeyB64, - relay: { endpoint: relayPublicEndpoint }, + relay: { endpoint: relayPublicEndpoint, useTls: relayUseTls }, }); const url = encodeOfferToFragmentUrl({ offer, appBaseUrl }); diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 96b848725..8f4a4dde7 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -15,6 +15,23 @@ describe("PersistedConfigSchema daemon auth config", () => { }); }); +describe("PersistedConfigSchema daemon relay config", () => { + test("accepts optional relay TLS setting", () => { + const parsed = PersistedConfigSchema.parse({ + daemon: { + relay: { + enabled: true, + endpoint: "relay.example.com:443", + publicEndpoint: "public.example.com:443", + useTls: true, + }, + }, + }); + + expect(parsed.daemon?.relay?.useTls).toBe(true); + }); +}); + describe("PersistedConfigSchema agent provider runtime settings", () => { test("legacy append entries are skipped during migration", () => { const parsed = PersistedConfigSchema.parse({ diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index d825dfe8a..f8eb0fc9d 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -258,6 +258,7 @@ export const PersistedConfigSchema = z enabled: z.boolean().optional(), endpoint: z.string().optional(), publicEndpoint: z.string().optional(), + useTls: z.boolean().optional(), }) .strict() .optional(), diff --git a/packages/server/src/server/relay-transport.test.ts b/packages/server/src/server/relay-transport.test.ts index 4d0985021..517781451 100644 --- a/packages/server/src/server/relay-transport.test.ts +++ b/packages/server/src/server/relay-transport.test.ts @@ -133,6 +133,7 @@ describe("relay-transport control lifecycle", () => { logger: logger as unknown as pino.Logger, attachSocket: async () => {}, relayEndpoint: "relay.paseo.sh:443", + relayUseTls: true, serverId: "srv_test", }); controllers.push(controller); @@ -155,6 +156,7 @@ describe("relay-transport control lifecycle", () => { logger: logger as unknown as pino.Logger, attachSocket: async () => {}, relayEndpoint: "relay.paseo.sh:443", + relayUseTls: true, serverId: "srv_test", }); controllers.push(controller); @@ -177,6 +179,7 @@ describe("relay-transport control lifecycle", () => { logger: logger as unknown as pino.Logger, attachSocket: async () => {}, relayEndpoint: "relay.paseo.sh:443", + relayUseTls: true, serverId: "srv_test", }); controllers.push(controller); @@ -198,6 +201,7 @@ describe("relay-transport control lifecycle", () => { logger: logger as unknown as pino.Logger, attachSocket, relayEndpoint: "relay.paseo.sh:443", + relayUseTls: true, serverId: "srv_test", }); controllers.push(controller); @@ -219,4 +223,24 @@ describe("relay-transport control lifecycle", () => { externalSessionKey: "session:clt_test", }); }); + + test("uses relayUseTls for control and data socket URLs", () => { + const logger = createMockLogger(); + const controller = startRelayTransport({ + logger: logger as unknown as pino.Logger, + attachSocket: async () => {}, + relayEndpoint: "[::1]:443", + relayUseTls: true, + serverId: "srv_test", + }); + controllers.push(controller); + + const control = MockWebSocket.instances[0]; + control.open(); + control.message(JSON.stringify({ type: "pong", ts: Date.now() })); + control.message(JSON.stringify({ type: "connected", connectionId: "clt_test" })); + + expect(MockWebSocket.instances[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/); + expect(MockWebSocket.instances[1]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/); + }); }); diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index f3251bab8..fe9cf198c 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -8,16 +8,14 @@ import { type Transport as RelayTransport, type KeyPair, } from "@getpaseo/relay/e2ee"; -import { - buildRelayWebSocketUrl, - shouldUseTlsForDefaultHostedRelay, -} from "../shared/daemon-endpoints.js"; +import { buildRelayWebSocketUrl } from "../shared/daemon-endpoints.js"; import type { ExternalSocketMetadata } from "./websocket-server.js"; interface RelayTransportOptions { logger: pino.Logger; attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise; relayEndpoint: string; // "host:port" + relayUseTls: boolean; serverId: string; daemonKeyPair?: KeyPair; } @@ -105,6 +103,7 @@ export function startRelayTransport({ logger, attachSocket, relayEndpoint, + relayUseTls, serverId, daemonKeyPair, }: RelayTransportOptions): RelayTransportController { @@ -158,7 +157,7 @@ export function startRelayTransport({ const connectionId = ++controlConnectionSeq; const url = buildRelayWebSocketUrl({ endpoint: relayEndpoint, - useTls: shouldUseTlsForDefaultHostedRelay(relayEndpoint), + useTls: relayUseTls, serverId, role: "server", }); @@ -334,7 +333,7 @@ export function startRelayTransport({ const url = buildRelayWebSocketUrl({ endpoint: relayEndpoint, - useTls: shouldUseTlsForDefaultHostedRelay(relayEndpoint), + useTls: relayUseTls, serverId, role: "server", connectionId, diff --git a/packages/server/src/shared/connection-offer.test.ts b/packages/server/src/shared/connection-offer.test.ts index 0c9c7db83..562b4897b 100644 --- a/packages/server/src/shared/connection-offer.test.ts +++ b/packages/server/src/shared/connection-offer.test.ts @@ -40,6 +40,39 @@ describe("connection offer", () => { expect(parseConnectionOfferFromUrl(`https://app.paseo.sh/#offer=${encoded}`)).toEqual(offer); }); + it("defaults relay TLS to false when absent", () => { + expect( + ConnectionOfferSchema.parse({ + v: 2, + serverId: "server-123", + daemonPublicKeyB64: "pubkey", + relay: { endpoint: "relay.example.com:80" }, + }), + ).toEqual({ + v: 2, + serverId: "server-123", + daemonPublicKeyB64: "pubkey", + relay: { endpoint: "relay.example.com:80", useTls: false }, + }); + }); + + it("round-trips relay TLS in offers without rejecting extra relay fields", () => { + const offer = ConnectionOfferSchema.parse({ + v: 2, + serverId: "server-123", + daemonPublicKeyB64: "pubkey", + relay: { endpoint: "relay.example.com:443", useTls: true, extra: "future" }, + }); + const encoded = encodeBase64UrlNoPadUtf8(JSON.stringify(offer)); + + expect(parseConnectionOfferFromUrl(`https://app.paseo.sh/#offer=${encoded}`)).toEqual({ + v: 2, + serverId: "server-123", + daemonPublicKeyB64: "pubkey", + relay: { endpoint: "relay.example.com:443", useTls: true }, + }); + }); + it("returns null when the URL has no offer fragment", () => { expect(parseConnectionOfferFromUrl("https://app.paseo.sh/pair")).toBeNull(); }); diff --git a/packages/server/src/shared/connection-offer.ts b/packages/server/src/shared/connection-offer.ts index 61c1aaf7e..6ba5e5159 100644 --- a/packages/server/src/shared/connection-offer.ts +++ b/packages/server/src/shared/connection-offer.ts @@ -12,6 +12,7 @@ export const ConnectionOfferV2Schema = z.object({ daemonPublicKeyB64: z.string().min(1), relay: z.object({ endpoint: z.string().min(1), + useTls: z.boolean().optional().default(false), }), }); diff --git a/packages/server/src/shared/daemon-endpoints.test.ts b/packages/server/src/shared/daemon-endpoints.test.ts index 0a4a69540..34522ff49 100644 --- a/packages/server/src/shared/daemon-endpoints.test.ts +++ b/packages/server/src/shared/daemon-endpoints.test.ts @@ -4,6 +4,7 @@ import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl, CURRENT_RELAY_PROTOCOL_VERSION, + extractHostPortFromWebSocketUrl, normalizeRelayProtocolVersion, parseConnectionUri, serializeConnectionUri, @@ -173,4 +174,17 @@ describe("relay websocket URLs", () => { expect(url.protocol).toBe("wss:"); }); + + test("round-trips IPv6 relay endpoints with TLS enabled", () => { + const wsUrl = buildRelayWebSocketUrl({ + endpoint: "[::1]:443", + useTls: true, + serverId: "srv_test", + role: "client", + }); + const url = new URL(wsUrl); + + expect(url.protocol).toBe("wss:"); + expect(extractHostPortFromWebSocketUrl(wsUrl)).toBe("[::1]:443"); + }); }); diff --git a/packages/server/src/shared/daemon-endpoints.ts b/packages/server/src/shared/daemon-endpoints.ts index 5a4040814..da98944a5 100644 --- a/packages/server/src/shared/daemon-endpoints.ts +++ b/packages/server/src/shared/daemon-endpoints.ts @@ -198,6 +198,10 @@ export function buildRelayWebSocketUrl(params: { return url.toString(); } +/** + * @deprecated Migration fallback for stored relay connections/offers that predate + * explicit relay useTls. Delete after two release cycles. + */ export function shouldUseTlsForDefaultHostedRelay(endpoint: string): boolean { try { return normalizeHostPort(endpoint) === DEFAULT_RELAY_ENDPOINT;