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

View File

@@ -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 }));
}

View File

@@ -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<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 {
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,

View File

@@ -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]

View File

@@ -5,6 +5,7 @@
"type": "module",
"exports": {
".": "./src/index.ts",
"./e2ee": "./src/e2ee.ts",
"./node": "./src/node-adapter.ts",
"./cloudflare": "./src/cloudflare-adapter.ts"
},

View File

@@ -0,0 +1,7 @@
export {
createClientChannel,
createDaemonChannel,
EncryptedChannel,
} from "./encrypted-channel.js";
export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js";

View File

@@ -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,

View File

@@ -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 });

View File

@@ -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<typeof ConnectionOfferV1Schema>;
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<ConnectionOfferV1> {
return ConnectionOfferV1Schema.parse({
v: 1,
sessionId: args.sessionId,
endpoints: args.endpoints,
daemonPublicKeyB64: args.daemonPublicKeyB64,
relay: args.relay ?? null,
});
}

View File

@@ -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) {

View File

@@ -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() {

View File

@@ -6,7 +6,7 @@ import {
createDaemonChannel,
type EncryptedChannel,
type Transport as RelayTransport,
} from "@paseo/relay";
} from "@paseo/relay/e2ee";
type RelayTransportOptions = {
logger: pino.Logger;

View File

@@ -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>;