From d68596bcbef5eef80a3aa3967fe6550b56e39996 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 3 Feb 2026 14:13:03 +0700 Subject: [PATCH] refactor: centralize relay vs direct E2EE --- .../components/multi-daemon-session-host.tsx | 11 +++--- .../src/contexts/daemon-registry-context.tsx | 25 +++++++------- packages/app/src/hooks/use-daemon-client.ts | 20 +++-------- packages/relay/package.json | 1 + packages/relay/src/e2ee.ts | 7 ++++ .../server/src/client/daemon-client-v2.ts | 34 +++++++++++++------ packages/server/src/server/bootstrap.ts | 3 +- .../server/src/server/connection-offer.ts | 23 ++++--------- .../daemon-e2e/connection-offer.e2e.test.ts | 6 +++- .../daemon-e2e/relay-transport.e2e.test.ts | 2 +- packages/server/src/server/relay-transport.ts | 2 +- .../server/src/shared/connection-offer.ts | 17 ++++++++++ 12 files changed, 83 insertions(+), 68 deletions(-) create mode 100644 packages/relay/src/e2ee.ts create mode 100644 packages/server/src/shared/connection-offer.ts diff --git a/packages/app/src/components/multi-daemon-session-host.tsx b/packages/app/src/components/multi-daemon-session-host.tsx index 2aeb3a60d..a5e97a625 100644 --- a/packages/app/src/components/multi-daemon-session-host.tsx +++ b/packages/app/src/components/multi-daemon-session-host.tsx @@ -22,11 +22,11 @@ function buildCandidateUrls(daemon: HostProfile): string[] { }; const isLastKnownRelay = !!relayEndpoint && lastKnownGood === relayEndpoint; - const directEndpoints = relayEndpoint - ? endpoints.filter((endpoint) => endpoint !== relayEndpoint) - : endpoints; + const directEndpoints = endpoints; - if (lastKnownGood && !isLastKnownRelay) { + if (sessionId && relayEndpoint && isLastKnownRelay) { + push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId })); + } else if (lastKnownGood) { push(buildDaemonWebSocketUrl(lastKnownGood)); } @@ -35,9 +35,6 @@ function buildCandidateUrls(daemon: HostProfile): string[] { } if (sessionId && relayEndpoint) { - if (isLastKnownRelay) { - push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId })); - } push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId })); } diff --git a/packages/app/src/contexts/daemon-registry-context.tsx b/packages/app/src/contexts/daemon-registry-context.tsx index d26e1d09a..e4db52d0c 100644 --- a/packages/app/src/contexts/daemon-registry-context.tsx +++ b/packages/app/src/contexts/daemon-registry-context.tsx @@ -2,7 +2,6 @@ import { createContext, useCallback, useContext } from "react"; import type { ReactNode } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { z } from "zod"; import { buildDaemonWebSocketUrl, decodeOfferFragmentPayload, @@ -10,6 +9,10 @@ import { extractHostPortFromWebSocketUrl, normalizeHostPort, } from "@/utils/daemon-endpoints"; +import { + ConnectionOfferV1Schema, + type ConnectionOfferV1, +} from "@server/shared/connection-offer"; const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry"; const LEGACY_SETTINGS_KEY = "@paseo:settings"; @@ -41,15 +44,6 @@ type CreateHostInput = { type UpdateHostInput = Partial>; -const ConnectionOfferV1Schema = z.object({ - v: z.literal(1), - sessionId: z.string().min(1), - endpoints: z.array(z.string().min(1)).min(1), - daemonPublicKeyB64: z.string().min(1), -}); - -export type ConnectionOfferV1 = z.infer; - interface DaemonRegistryContextValue { daemons: HostProfile[]; isLoading: boolean; @@ -134,7 +128,12 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { const existing = readDaemons(); const now = new Date().toISOString(); const normalizedEndpoints = offer.endpoints.map((endpoint) => normalizeHostPort(endpoint)); - const relayEndpoint = normalizedEndpoints[normalizedEndpoints.length - 1]; + const relayEndpoint = + offer.relay?.endpoint + ? normalizeHostPort(offer.relay.endpoint) + : offer.relay === undefined && normalizedEndpoints.length > 0 + ? normalizedEndpoints[normalizedEndpoints.length - 1] + : null; const matchIndex = existing.findIndex((daemon) => daemon.daemonPublicKeyB64 === offer.daemonPublicKeyB64); if (matchIndex !== -1) { @@ -142,7 +141,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { ...existing[matchIndex], daemonPublicKeyB64: offer.daemonPublicKeyB64, endpoints: normalizedEndpoints, - relay: { endpoint: relayEndpoint, sessionId: offer.sessionId }, + relay: relayEndpoint ? { endpoint: relayEndpoint, sessionId: offer.sessionId } : null, updatedAt: now, }; const next = [...existing]; @@ -156,7 +155,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { label: deriveLabelFromEndpoint(normalizedEndpoints[0] ?? "Unnamed Host"), endpoints: normalizedEndpoints, daemonPublicKeyB64: offer.daemonPublicKeyB64, - relay: { endpoint: relayEndpoint, sessionId: offer.sessionId }, + relay: relayEndpoint ? { endpoint: relayEndpoint, sessionId: offer.sessionId } : null, createdAt: now, updatedAt: now, metadata: null, diff --git a/packages/app/src/hooks/use-daemon-client.ts b/packages/app/src/hooks/use-daemon-client.ts index f3ac56f45..fb5d26568 100644 --- a/packages/app/src/hooks/use-daemon-client.ts +++ b/packages/app/src/hooks/use-daemon-client.ts @@ -13,15 +13,6 @@ type DaemonClientOptions = { daemonPublicKeyB64?: string; }; -function isRelayWebSocketUrl(url: string): boolean { - try { - const parsed = new URL(url); - return parsed.searchParams.get("role") === "client" && parsed.searchParams.has("session"); - } catch { - return false; - } -} - export function useDaemonClient( url: string, options: DaemonClientOptions = {} @@ -29,21 +20,18 @@ export function useDaemonClient( const client = useMemo( () => { const tauriTransportFactory = createTauriWebSocketTransportFactory(); - const relayConnection = isRelayWebSocketUrl(url); return new DaemonClientV2({ url, suppressSendErrors: true, ...(tauriTransportFactory ? { transportFactory: tauriTransportFactory } : {}), - ...(relayConnection + e2ee: options.daemonPublicKeyB64 ? { - e2ee: { - enabled: true, - daemonPublicKeyB64: options.daemonPublicKeyB64, - }, + enabled: true, + daemonPublicKeyB64: options.daemonPublicKeyB64, } - : {}), + : undefined, }); }, [options.daemonPublicKeyB64, url] diff --git a/packages/relay/package.json b/packages/relay/package.json index b0973d931..a6b1dfc18 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -5,6 +5,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./e2ee": "./src/e2ee.ts", "./node": "./src/node-adapter.ts", "./cloudflare": "./src/cloudflare-adapter.ts" }, diff --git a/packages/relay/src/e2ee.ts b/packages/relay/src/e2ee.ts new file mode 100644 index 000000000..a26e97fab --- /dev/null +++ b/packages/relay/src/e2ee.ts @@ -0,0 +1,7 @@ +export { + createClientChannel, + createDaemonChannel, + EncryptedChannel, +} from "./encrypted-channel.js"; +export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js"; + diff --git a/packages/server/src/client/daemon-client-v2.ts b/packages/server/src/client/daemon-client-v2.ts index a9ab4f5d8..fab6edbd2 100644 --- a/packages/server/src/client/daemon-client-v2.ts +++ b/packages/server/src/client/daemon-client-v2.ts @@ -57,7 +57,7 @@ import { createClientChannel, type EncryptedChannel, type Transport as RelayTransport, -} from "@paseo/relay"; +} from "@paseo/relay/e2ee"; export interface Logger { debug(obj: object, msg?: string): void; @@ -319,14 +319,16 @@ export class DaemonClientV2 { createWebSocketTransportFactory( this.config.webSocketFactory ?? defaultWebSocketFactory ); - const transportFactory = - this.config.e2ee?.enabled === true - ? createEncryptedTransportFactory( - baseTransportFactory, - this.config.e2ee?.daemonPublicKeyB64, - this.logger - ) - : baseTransportFactory; + const shouldUseRelayE2ee = + this.config.e2ee?.enabled === true && + isRelayClientWebSocketUrl(this.config.url); + const transportFactory = shouldUseRelayE2ee + ? createRelayE2eeTransportFactory( + baseTransportFactory, + this.config.e2ee?.daemonPublicKeyB64, + this.logger + ) + : baseTransportFactory; const transport = transportFactory({ url: this.config.url, headers }); this.transport = transport; @@ -2342,7 +2344,7 @@ function createWebSocketTransportFactory( }; } -function createEncryptedTransportFactory( +function createRelayE2eeTransportFactory( baseFactory: DaemonTransportFactory, daemonPublicKeyB64: string | undefined, logger: Logger @@ -2357,6 +2359,18 @@ function createEncryptedTransportFactory( }; } +function isRelayClientWebSocketUrl(url: string): boolean { + try { + const parsed = new URL(url); + return ( + parsed.searchParams.get("role") === "client" && + parsed.searchParams.has("session") + ); + } catch { + return false; + } +} + function createEncryptedTransport( base: DaemonTransport, daemonPublicKeyB64: string, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 8bffecd58..9e94c7202 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -437,14 +437,13 @@ export async function createPaseoDaemon( const endpoints = buildOfferEndpoints({ listenHost: listenTarget.host, port: listenTarget.port, - relayEnabled, - relayEndpoint, }); const offer = await createConnectionOfferV1({ sessionId: connectionSessionId, endpoints, daemonPublicKeyB64: daemonKeyPair.publicKeyB64, + relay: relayEnabled ? { endpoint: relayEndpoint } : null, }); const url = encodeOfferToFragmentUrl({ offer, appBaseUrl }); diff --git a/packages/server/src/server/connection-offer.ts b/packages/server/src/server/connection-offer.ts index 77fb80f00..540b1b76e 100644 --- a/packages/server/src/server/connection-offer.ts +++ b/packages/server/src/server/connection-offer.ts @@ -1,27 +1,18 @@ import os from "node:os"; -import { z } from "zod"; -export const ConnectionOfferV1Schema = z.object({ - v: z.literal(1), - sessionId: z.string().min(1), - endpoints: z.array(z.string().min(1)).min(1), - daemonPublicKeyB64: z.string().min(1), -}); - -export type ConnectionOfferV1 = z.infer; +import { + ConnectionOfferV1Schema, + type ConnectionOfferV1, +} from "../shared/connection-offer.js"; type BuildOfferEndpointsArgs = { listenHost: string; port: number; - relayEnabled: boolean; - relayEndpoint: string; }; export function buildOfferEndpoints({ listenHost, port, - relayEnabled, - relayEndpoint, }: BuildOfferEndpointsArgs): string[] { const endpoints: string[] = []; @@ -41,10 +32,6 @@ export function buildOfferEndpoints({ endpoints.push(`localhost:${port}`); endpoints.push(`127.0.0.1:${port}`); - if (relayEnabled) { - endpoints.push(relayEndpoint); - } - return dedupePreserveOrder(endpoints); } @@ -52,12 +39,14 @@ export async function createConnectionOfferV1(args: { sessionId: string; endpoints: string[]; daemonPublicKeyB64: string; + relay?: { endpoint: string } | null; }): Promise { return ConnectionOfferV1Schema.parse({ v: 1, sessionId: args.sessionId, endpoints: args.endpoints, daemonPublicKeyB64: args.daemonPublicKeyB64, + relay: args.relay ?? null, }); } diff --git a/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts b/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts index 82f9617c5..d0ddc9388 100644 --- a/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts @@ -89,6 +89,7 @@ describe("ConnectionOfferV1 (daemon E2E)", () => { sessionId: string; endpoints: string[]; daemonPublicKeyB64: string; + relay?: { endpoint: string } | null; }; expect(offer.v).toBe(1); @@ -97,7 +98,8 @@ describe("ConnectionOfferV1 (daemon E2E)", () => { expect(Array.isArray(offer.endpoints)).toBe(true); expect(offer.endpoints).toContain(`192.168.1.12:${daemon.port}`); expect(offer.endpoints).toContain(`localhost:${daemon.port}`); - expect(offer.endpoints).toContain("relay.paseo.sh:443"); + expect(offer.endpoints).not.toContain("relay.paseo.sh:443"); + expect(offer.relay?.endpoint).toBe("relay.paseo.sh:443"); expect(typeof offer.daemonPublicKeyB64).toBe("string"); expect(offer.daemonPublicKeyB64.length).toBeGreaterThan(0); expect(() => Buffer.from(offer.daemonPublicKeyB64, "base64")).not.toThrow(); @@ -167,9 +169,11 @@ describe("ConnectionOfferV1 (daemon E2E)", () => { const offer = decodeOfferFromFragmentUrl(offerUrl) as { endpoints: string[]; + relay?: { endpoint: string } | null; }; expect(offer.endpoints).not.toContain("relay.paseo.sh:443"); + expect(offer.relay).toBe(null); expect(offer.endpoints).toContain(`localhost:${port}`); expect(offer.endpoints).toContain(`192.168.1.12:${port}`); } catch (err) { diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index 731ecc488..30837c251 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -5,7 +5,7 @@ import { Writable } from "node:stream"; import net from "node:net"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; -import { createClientChannel, type Transport } from "@paseo/relay"; +import { createClientChannel, type Transport } from "@paseo/relay/e2ee"; import { createRelayServer } from "@paseo/relay/node"; function createCapturingLogger() { diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index 7a0012c46..3a33d8219 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -6,7 +6,7 @@ import { createDaemonChannel, type EncryptedChannel, type Transport as RelayTransport, -} from "@paseo/relay"; +} from "@paseo/relay/e2ee"; type RelayTransportOptions = { logger: pino.Logger; diff --git a/packages/server/src/shared/connection-offer.ts b/packages/server/src/shared/connection-offer.ts new file mode 100644 index 000000000..37be5c9bb --- /dev/null +++ b/packages/server/src/shared/connection-offer.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const ConnectionOfferV1Schema = z.object({ + v: z.literal(1), + sessionId: z.string().min(1), + endpoints: z.array(z.string().min(1)).min(1), + daemonPublicKeyB64: z.string().min(1), + relay: z + .object({ + endpoint: z.string().min(1), + }) + .nullable() + .optional(), +}); + +export type ConnectionOfferV1 = z.infer; +