refactor: centralize relay vs direct E2EE

This commit is contained in:
Mohamed Boudra
2026-02-03 14:13:03 +07:00
parent 4fb93d92ed
commit d68596bcbe
12 changed files with 83 additions and 68 deletions
@@ -22,11 +22,11 @@ function buildCandidateUrls(daemon: HostProfile): string[] {
}; };
const isLastKnownRelay = !!relayEndpoint && lastKnownGood === relayEndpoint; const isLastKnownRelay = !!relayEndpoint && lastKnownGood === relayEndpoint;
const directEndpoints = relayEndpoint const directEndpoints = endpoints;
? endpoints.filter((endpoint) => endpoint !== relayEndpoint)
: endpoints;
if (lastKnownGood && !isLastKnownRelay) { if (sessionId && relayEndpoint && isLastKnownRelay) {
push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId }));
} else if (lastKnownGood) {
push(buildDaemonWebSocketUrl(lastKnownGood)); push(buildDaemonWebSocketUrl(lastKnownGood));
} }
@@ -35,9 +35,6 @@ function buildCandidateUrls(daemon: HostProfile): string[] {
} }
if (sessionId && relayEndpoint) { if (sessionId && relayEndpoint) {
if (isLastKnownRelay) {
push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId }));
}
push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId })); push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId }));
} }
@@ -2,7 +2,6 @@ import { createContext, useCallback, useContext } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import { import {
buildDaemonWebSocketUrl, buildDaemonWebSocketUrl,
decodeOfferFragmentPayload, decodeOfferFragmentPayload,
@@ -10,6 +9,10 @@ import {
extractHostPortFromWebSocketUrl, extractHostPortFromWebSocketUrl,
normalizeHostPort, normalizeHostPort,
} from "@/utils/daemon-endpoints"; } from "@/utils/daemon-endpoints";
import {
ConnectionOfferV1Schema,
type ConnectionOfferV1,
} from "@server/shared/connection-offer";
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry"; const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
const LEGACY_SETTINGS_KEY = "@paseo:settings"; const LEGACY_SETTINGS_KEY = "@paseo:settings";
@@ -41,15 +44,6 @@ type CreateHostInput = {
type UpdateHostInput = Partial<Omit<HostProfile, "id" | "createdAt">>; type UpdateHostInput = Partial<Omit<HostProfile, "id" | "createdAt">>;
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<typeof ConnectionOfferV1Schema>;
interface DaemonRegistryContextValue { interface DaemonRegistryContextValue {
daemons: HostProfile[]; daemons: HostProfile[];
isLoading: boolean; isLoading: boolean;
@@ -134,7 +128,12 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const existing = readDaemons(); const existing = readDaemons();
const now = new Date().toISOString(); const now = new Date().toISOString();
const normalizedEndpoints = offer.endpoints.map((endpoint) => normalizeHostPort(endpoint)); 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); const matchIndex = existing.findIndex((daemon) => daemon.daemonPublicKeyB64 === offer.daemonPublicKeyB64);
if (matchIndex !== -1) { if (matchIndex !== -1) {
@@ -142,7 +141,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
...existing[matchIndex], ...existing[matchIndex],
daemonPublicKeyB64: offer.daemonPublicKeyB64, daemonPublicKeyB64: offer.daemonPublicKeyB64,
endpoints: normalizedEndpoints, endpoints: normalizedEndpoints,
relay: { endpoint: relayEndpoint, sessionId: offer.sessionId }, relay: relayEndpoint ? { endpoint: relayEndpoint, sessionId: offer.sessionId } : null,
updatedAt: now, updatedAt: now,
}; };
const next = [...existing]; const next = [...existing];
@@ -156,7 +155,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
label: deriveLabelFromEndpoint(normalizedEndpoints[0] ?? "Unnamed Host"), label: deriveLabelFromEndpoint(normalizedEndpoints[0] ?? "Unnamed Host"),
endpoints: normalizedEndpoints, endpoints: normalizedEndpoints,
daemonPublicKeyB64: offer.daemonPublicKeyB64, daemonPublicKeyB64: offer.daemonPublicKeyB64,
relay: { endpoint: relayEndpoint, sessionId: offer.sessionId }, relay: relayEndpoint ? { endpoint: relayEndpoint, sessionId: offer.sessionId } : null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
metadata: null, metadata: null,
+4 -16
View File
@@ -13,15 +13,6 @@ type DaemonClientOptions = {
daemonPublicKeyB64?: string; 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( export function useDaemonClient(
url: string, url: string,
options: DaemonClientOptions = {} options: DaemonClientOptions = {}
@@ -29,21 +20,18 @@ export function useDaemonClient(
const client = useMemo( const client = useMemo(
() => { () => {
const tauriTransportFactory = createTauriWebSocketTransportFactory(); const tauriTransportFactory = createTauriWebSocketTransportFactory();
const relayConnection = isRelayWebSocketUrl(url);
return new DaemonClientV2({ return new DaemonClientV2({
url, url,
suppressSendErrors: true, suppressSendErrors: true,
...(tauriTransportFactory ...(tauriTransportFactory
? { transportFactory: tauriTransportFactory } ? { transportFactory: tauriTransportFactory }
: {}), : {}),
...(relayConnection e2ee: options.daemonPublicKeyB64
? { ? {
e2ee: { enabled: true,
enabled: true, daemonPublicKeyB64: options.daemonPublicKeyB64,
daemonPublicKeyB64: options.daemonPublicKeyB64,
},
} }
: {}), : undefined,
}); });
}, },
[options.daemonPublicKeyB64, url] [options.daemonPublicKeyB64, url]
+1
View File
@@ -5,6 +5,7 @@
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./e2ee": "./src/e2ee.ts",
"./node": "./src/node-adapter.ts", "./node": "./src/node-adapter.ts",
"./cloudflare": "./src/cloudflare-adapter.ts" "./cloudflare": "./src/cloudflare-adapter.ts"
}, },
+7
View File
@@ -0,0 +1,7 @@
export {
createClientChannel,
createDaemonChannel,
EncryptedChannel,
} from "./encrypted-channel.js";
export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js";
+24 -10
View File
@@ -57,7 +57,7 @@ import {
createClientChannel, createClientChannel,
type EncryptedChannel, type EncryptedChannel,
type Transport as RelayTransport, type Transport as RelayTransport,
} from "@paseo/relay"; } from "@paseo/relay/e2ee";
export interface Logger { export interface Logger {
debug(obj: object, msg?: string): void; debug(obj: object, msg?: string): void;
@@ -319,14 +319,16 @@ export class DaemonClientV2 {
createWebSocketTransportFactory( createWebSocketTransportFactory(
this.config.webSocketFactory ?? defaultWebSocketFactory this.config.webSocketFactory ?? defaultWebSocketFactory
); );
const transportFactory = const shouldUseRelayE2ee =
this.config.e2ee?.enabled === true this.config.e2ee?.enabled === true &&
? createEncryptedTransportFactory( isRelayClientWebSocketUrl(this.config.url);
baseTransportFactory, const transportFactory = shouldUseRelayE2ee
this.config.e2ee?.daemonPublicKeyB64, ? createRelayE2eeTransportFactory(
this.logger baseTransportFactory,
) this.config.e2ee?.daemonPublicKeyB64,
: baseTransportFactory; this.logger
)
: baseTransportFactory;
const transport = transportFactory({ url: this.config.url, headers }); const transport = transportFactory({ url: this.config.url, headers });
this.transport = transport; this.transport = transport;
@@ -2342,7 +2344,7 @@ function createWebSocketTransportFactory(
}; };
} }
function createEncryptedTransportFactory( function createRelayE2eeTransportFactory(
baseFactory: DaemonTransportFactory, baseFactory: DaemonTransportFactory,
daemonPublicKeyB64: string | undefined, daemonPublicKeyB64: string | undefined,
logger: Logger 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( function createEncryptedTransport(
base: DaemonTransport, base: DaemonTransport,
daemonPublicKeyB64: string, daemonPublicKeyB64: string,
+1 -2
View File
@@ -437,14 +437,13 @@ export async function createPaseoDaemon(
const endpoints = buildOfferEndpoints({ const endpoints = buildOfferEndpoints({
listenHost: listenTarget.host, listenHost: listenTarget.host,
port: listenTarget.port, port: listenTarget.port,
relayEnabled,
relayEndpoint,
}); });
const offer = await createConnectionOfferV1({ const offer = await createConnectionOfferV1({
sessionId: connectionSessionId, sessionId: connectionSessionId,
endpoints, endpoints,
daemonPublicKeyB64: daemonKeyPair.publicKeyB64, daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
relay: relayEnabled ? { endpoint: relayEndpoint } : null,
}); });
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl }); const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
+6 -17
View File
@@ -1,27 +1,18 @@
import os from "node:os"; import os from "node:os";
import { z } from "zod";
export const ConnectionOfferV1Schema = z.object({ import {
v: z.literal(1), ConnectionOfferV1Schema,
sessionId: z.string().min(1), type ConnectionOfferV1,
endpoints: z.array(z.string().min(1)).min(1), } from "../shared/connection-offer.js";
daemonPublicKeyB64: z.string().min(1),
});
export type ConnectionOfferV1 = z.infer<typeof ConnectionOfferV1Schema>;
type BuildOfferEndpointsArgs = { type BuildOfferEndpointsArgs = {
listenHost: string; listenHost: string;
port: number; port: number;
relayEnabled: boolean;
relayEndpoint: string;
}; };
export function buildOfferEndpoints({ export function buildOfferEndpoints({
listenHost, listenHost,
port, port,
relayEnabled,
relayEndpoint,
}: BuildOfferEndpointsArgs): string[] { }: BuildOfferEndpointsArgs): string[] {
const endpoints: string[] = []; const endpoints: string[] = [];
@@ -41,10 +32,6 @@ export function buildOfferEndpoints({
endpoints.push(`localhost:${port}`); endpoints.push(`localhost:${port}`);
endpoints.push(`127.0.0.1:${port}`); endpoints.push(`127.0.0.1:${port}`);
if (relayEnabled) {
endpoints.push(relayEndpoint);
}
return dedupePreserveOrder(endpoints); return dedupePreserveOrder(endpoints);
} }
@@ -52,12 +39,14 @@ export async function createConnectionOfferV1(args: {
sessionId: string; sessionId: string;
endpoints: string[]; endpoints: string[];
daemonPublicKeyB64: string; daemonPublicKeyB64: string;
relay?: { endpoint: string } | null;
}): Promise<ConnectionOfferV1> { }): Promise<ConnectionOfferV1> {
return ConnectionOfferV1Schema.parse({ return ConnectionOfferV1Schema.parse({
v: 1, v: 1,
sessionId: args.sessionId, sessionId: args.sessionId,
endpoints: args.endpoints, endpoints: args.endpoints,
daemonPublicKeyB64: args.daemonPublicKeyB64, daemonPublicKeyB64: args.daemonPublicKeyB64,
relay: args.relay ?? null,
}); });
} }
@@ -89,6 +89,7 @@ describe("ConnectionOfferV1 (daemon E2E)", () => {
sessionId: string; sessionId: string;
endpoints: string[]; endpoints: string[];
daemonPublicKeyB64: string; daemonPublicKeyB64: string;
relay?: { endpoint: string } | null;
}; };
expect(offer.v).toBe(1); expect(offer.v).toBe(1);
@@ -97,7 +98,8 @@ describe("ConnectionOfferV1 (daemon E2E)", () => {
expect(Array.isArray(offer.endpoints)).toBe(true); expect(Array.isArray(offer.endpoints)).toBe(true);
expect(offer.endpoints).toContain(`192.168.1.12:${daemon.port}`); expect(offer.endpoints).toContain(`192.168.1.12:${daemon.port}`);
expect(offer.endpoints).toContain(`localhost:${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(typeof offer.daemonPublicKeyB64).toBe("string");
expect(offer.daemonPublicKeyB64.length).toBeGreaterThan(0); expect(offer.daemonPublicKeyB64.length).toBeGreaterThan(0);
expect(() => Buffer.from(offer.daemonPublicKeyB64, "base64")).not.toThrow(); expect(() => Buffer.from(offer.daemonPublicKeyB64, "base64")).not.toThrow();
@@ -167,9 +169,11 @@ describe("ConnectionOfferV1 (daemon E2E)", () => {
const offer = decodeOfferFromFragmentUrl(offerUrl) as { const offer = decodeOfferFromFragmentUrl(offerUrl) as {
endpoints: string[]; endpoints: string[];
relay?: { endpoint: string } | null;
}; };
expect(offer.endpoints).not.toContain("relay.paseo.sh:443"); expect(offer.endpoints).not.toContain("relay.paseo.sh:443");
expect(offer.relay).toBe(null);
expect(offer.endpoints).toContain(`localhost:${port}`); expect(offer.endpoints).toContain(`localhost:${port}`);
expect(offer.endpoints).toContain(`192.168.1.12:${port}`); expect(offer.endpoints).toContain(`192.168.1.12:${port}`);
} catch (err) { } catch (err) {
@@ -5,7 +5,7 @@ import { Writable } from "node:stream";
import net from "node:net"; import net from "node:net";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; 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"; import { createRelayServer } from "@paseo/relay/node";
function createCapturingLogger() { function createCapturingLogger() {
@@ -6,7 +6,7 @@ import {
createDaemonChannel, createDaemonChannel,
type EncryptedChannel, type EncryptedChannel,
type Transport as RelayTransport, type Transport as RelayTransport,
} from "@paseo/relay"; } from "@paseo/relay/e2ee";
type RelayTransportOptions = { type RelayTransportOptions = {
logger: pino.Logger; logger: pino.Logger;
@@ -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<typeof ConnectionOfferV1Schema>;