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
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<string> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof loadPersistedConfig>;
|
||||
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,
|
||||
|
||||
@@ -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<ConnectionOffer> {
|
||||
return ConnectionOfferV2Schema.parse({
|
||||
v: 2,
|
||||
|
||||
@@ -40,6 +40,9 @@ function applyCliFlagOverrides(config: ReturnType<typeof loadConfig>): 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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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\?/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void>;
|
||||
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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user