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:
Mohamed Boudra
2026-05-06 17:25:16 +08:00
committed by GitHub
parent 6c319d05dd
commit 2e45650f22
33 changed files with 397 additions and 17 deletions

View File

@@ -140,6 +140,52 @@ npm run typecheck
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go - [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;
}
}
```
--- ---
<p align="center"> <p align="center">

View File

@@ -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 - Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`) - Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
- Pairing via QR code transfers the daemon's public key to the client - 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. See [SECURITY.md](../SECURITY.md) for the full threat model.

View File

@@ -133,7 +133,7 @@ Single file, validated with `PersistedConfigSchema`.
hostnames: true | string[], hostnames: true | string[],
mcp: { enabled: boolean }, mcp: { enabled: boolean },
cors: { allowedOrigins: string[] }, cors: { allowedOrigins: string[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string } relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean }
}, },
app: { app: {
baseUrl: string baseUrl: string

View File

@@ -178,6 +178,7 @@ export default function PairScanScreen() {
id: "probe", id: "probe",
type: "relay", type: "relay",
relayEndpoint: normalizeHostPort(offer.relay.endpoint), relayEndpoint: normalizeHostPort(offer.relay.endpoint),
useTls: offer.relay.useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64, daemonPublicKeyB64: offer.daemonPublicKeyB64,
}, },
{ serverId: offer.serverId }, { serverId: offer.serverId },

View File

@@ -137,6 +137,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
id: "probe", id: "probe",
type: "relay", type: "relay",
relayEndpoint: normalizeHostPort(parsedOffer.relay.endpoint), relayEndpoint: normalizeHostPort(parsedOffer.relay.endpoint),
useTls: parsedOffer.relay.useTls,
daemonPublicKeyB64: parsedOffer.daemonPublicKeyB64, daemonPublicKeyB64: parsedOffer.daemonPublicKeyB64,
}, },
{ serverId: parsedOffer.serverId }, { serverId: parsedOffer.serverId },

View File

@@ -198,6 +198,7 @@ function makeOffer(input?: Partial<ConnectionOffer>): ConnectionOffer {
daemonPublicKeyB64: input?.daemonPublicKeyB64 ?? "pk_test_offer", daemonPublicKeyB64: input?.daemonPublicKeyB64 ?? "pk_test_offer",
relay: { relay: {
endpoint: input?.relay?.endpoint ?? "relay.paseo.sh:443", endpoint: input?.relay?.endpoint ?? "relay.paseo.sh:443",
useTls: input?.relay?.useTls ?? false,
}, },
}; };
} }
@@ -1616,6 +1617,43 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]); 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 () => { it("uses the latest advertised hostname when re-pairing an existing relay host", async () => {
const store = new HostRuntimeStore({ const store = new HostRuntimeStore({
deps: { deps: {

View File

@@ -476,7 +476,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
...base, ...base,
url: buildRelayWebSocketUrl({ url: buildRelayWebSocketUrl({
endpoint: connection.relayEndpoint, endpoint: connection.relayEndpoint,
useTls: shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint), useTls: connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint),
serverId: host.serverId, serverId: host.serverId,
}), }),
e2ee: { e2ee: {
@@ -1414,21 +1414,25 @@ export class HostRuntimeStore {
async upsertRelayConnection(input: { async upsertRelayConnection(input: {
serverId: string; serverId: string;
relayEndpoint: string; relayEndpoint: string;
useTls?: boolean;
daemonPublicKeyB64: string; daemonPublicKeyB64: string;
label?: string; label?: string;
}): Promise<HostProfile> { }): Promise<HostProfile> {
const relayEndpoint = normalizeHostPort(input.relayEndpoint); const relayEndpoint = normalizeHostPort(input.relayEndpoint);
const useTls = input.useTls ?? false;
const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim(); const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim();
if (!daemonPublicKeyB64) { if (!daemonPublicKeyB64) {
throw new Error("daemonPublicKeyB64 is required"); throw new Error("daemonPublicKeyB64 is required");
} }
const explicitUseTls = input.useTls !== undefined;
return this.upsertHostConnection({ return this.upsertHostConnection({
serverId: input.serverId, serverId: input.serverId,
label: input.label, label: input.label,
connection: { connection: {
id: `relay:${relayEndpoint}`, id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`,
type: "relay", type: "relay",
relayEndpoint, relayEndpoint,
...(explicitUseTls ? { useTls } : {}),
daemonPublicKeyB64, daemonPublicKeyB64,
}, },
}); });
@@ -1438,6 +1442,7 @@ export class HostRuntimeStore {
return this.upsertRelayConnection({ return this.upsertRelayConnection({
serverId: offer.serverId, serverId: offer.serverId,
relayEndpoint: offer.relay.endpoint, relayEndpoint: offer.relay.endpoint,
useTls: offer.relay.useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64, daemonPublicKeyB64: offer.daemonPublicKeyB64,
label, label,
}); });
@@ -1983,6 +1988,7 @@ export interface HostMutations {
upsertRelayConnection: (input: { upsertRelayConnection: (input: {
serverId: string; serverId: string;
relayEndpoint: string; relayEndpoint: string;
useTls?: boolean;
daemonPublicKeyB64: string; daemonPublicKeyB64: string;
label?: string; label?: string;
}) => Promise<HostProfile>; }) => Promise<HostProfile>;

View File

@@ -27,4 +27,48 @@ describe("normalizeStoredHostProfile", () => {
}); });
expect(profile?.connections[0]).not.toHaveProperty("password"); 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",
});
});
}); });

View File

@@ -22,6 +22,7 @@ export interface RelayHostConnection {
id: string; id: string;
type: "relay"; type: "relay";
relayEndpoint: string; relayEndpoint: string;
useTls?: boolean;
daemonPublicKeyB64: string; daemonPublicKeyB64: string;
} }
@@ -73,6 +74,7 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool
if (left.type === "relay" && right.type === "relay") { if (left.type === "relay" && right.type === "relay") {
return ( return (
left.relayEndpoint === right.relayEndpoint && left.relayEndpoint === right.relayEndpoint &&
left.useTls === right.useTls &&
left.daemonPublicKeyB64 === right.daemonPublicKeyB64 left.daemonPublicKeyB64 === right.daemonPublicKeyB64
); );
} }
@@ -243,7 +245,7 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
if (!record) { if (!record) {
return null; return null;
} }
const type = typeof record.type === "string" ? record.type : null; const type = record.type;
if (type === "directTcp") { if (type === "directTcp") {
try { try {
const endpoint = normalizeLoopbackToLocalhost( const endpoint = normalizeLoopbackToLocalhost(
@@ -277,10 +279,12 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
typeof record.daemonPublicKeyB64 === "string" ? record.daemonPublicKeyB64 : "" typeof record.daemonPublicKeyB64 === "string" ? record.daemonPublicKeyB64 : ""
).trim(); ).trim();
if (!daemonPublicKeyB64) return null; if (!daemonPublicKeyB64) return null;
const useTls = typeof record.useTls === "boolean" ? record.useTls : undefined;
return { return {
id: `relay:${relayEndpoint}`, id: useTls === true ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`,
type: "relay", type: "relay",
relayEndpoint, relayEndpoint,
...(useTls !== undefined ? { useTls } : {}),
daemonPublicKeyB64, daemonPublicKeyB64,
}; };
} catch { } catch {

View File

@@ -145,6 +145,37 @@ describe("test-daemon-connection connectToDaemon", () => {
expect(daemonClientMock.createdConfigs[0]?.password).toBe("shared-secret"); 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 () => { it("surfaces auth rejection as an incorrect password", async () => {
const mod = await import("./test-daemon-connection"); const mod = await import("./test-daemon-connection");
daemonClientMock.setNextConnectFailure( daemonClientMock.setNextConnectFailure(

View File

@@ -110,7 +110,7 @@ export async function buildClientConfig(
...base, ...base,
url: buildRelayWebSocketUrl({ url: buildRelayWebSocketUrl({
endpoint: connection.relayEndpoint, endpoint: connection.relayEndpoint,
useTls: shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint), useTls: connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint),
serverId, serverId,
}), }),
e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 }, e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 },

View File

@@ -77,4 +77,23 @@ describe("local daemon launch supervision", () => {
expectSupervisorLaunch(argv); expectSupervisorLaunch(argv);
expect(argv).toContain("--no-mcp"); 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");
});
}); });

View File

@@ -11,6 +11,7 @@ export interface DaemonStartOptions {
home?: string; home?: string;
foreground?: boolean; foreground?: boolean;
relay?: boolean; relay?: boolean;
relayUseTls?: boolean;
mcp?: boolean; mcp?: boolean;
injectMcp?: boolean; injectMcp?: boolean;
hostnames?: string; hostnames?: string;
@@ -28,6 +29,9 @@ export interface LocalDaemonPidInfo {
export interface LocalDaemonState { export interface LocalDaemonState {
home: string; home: string;
listen: string; listen: string;
relayEnabled: boolean;
relayEndpoint: string;
relayUseTls: boolean;
logPath: string; logPath: string;
pidPath: string; pidPath: string;
pidInfo: LocalDaemonPidInfo | null; pidInfo: LocalDaemonPidInfo | null;
@@ -93,6 +97,9 @@ function buildRunnerArgs(options: DaemonStartOptions): string[] {
if (options.relay === false) { if (options.relay === false) {
args.push("--no-relay"); args.push("--no-relay");
} }
if (options.relayUseTls === true) {
args.push("--relay-use-tls");
}
if (options.mcp === false) { if (options.mcp === false) {
args.push("--no-mcp"); args.push("--no-mcp");
@@ -117,6 +124,9 @@ function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv {
if (options.hostnames) { if (options.hostnames) {
childEnv.PASEO_HOSTNAMES = options.hostnames; childEnv.PASEO_HOSTNAMES = options.hostnames;
} }
if (options.relayUseTls === true) {
childEnv.PASEO_RELAY_USE_TLS = "true";
}
return childEnv; return childEnv;
} }
@@ -353,6 +363,9 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD
return { return {
home, home,
listen, listen,
relayEnabled: config.relayEnabled ?? true,
relayEndpoint: config.relayPublicEndpoint ?? config.relayEndpoint ?? "relay.paseo.sh:443",
relayUseTls: config.relayUseTls ?? false,
logPath, logPath,
pidPath, pidPath,
pidInfo, pidInfo,

View File

@@ -28,6 +28,7 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
relayEnabled: config.relayEnabled, relayEnabled: config.relayEnabled,
relayEndpoint: config.relayEndpoint, relayEndpoint: config.relayEndpoint,
relayPublicEndpoint: config.relayPublicEndpoint, relayPublicEndpoint: config.relayPublicEndpoint,
relayUseTls: config.relayUseTls,
appBaseUrl: config.appBaseUrl, appBaseUrl: config.appBaseUrl,
includeQr: true, includeQr: true,
}); });

View File

@@ -21,6 +21,7 @@ export function startCommand(): Command {
.option("--home <path>", "Paseo home directory (default: ~/.paseo)") .option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--foreground", "Run in foreground (don't daemonize)") .option("--foreground", "Run in foreground (don't daemonize)")
.option("--no-relay", "Disable relay connection") .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-mcp", "Disable the Agent MCP HTTP endpoint")
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents") .option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
.option( .option(

View File

@@ -18,6 +18,7 @@ interface DaemonStatus {
connectedDaemon: "reachable" | "unreachable" | "not_probed"; connectedDaemon: "reachable" | "unreachable" | "not_probed";
home: string; home: string;
listen: string; listen: string;
relay: string;
hostname: string | null; hostname: string | null;
pid: number | null; pid: number | null;
startedAt: string | null; startedAt: string | null;
@@ -117,6 +118,7 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
{ key: "Connected Daemon", value: status.connectedDaemon }, { key: "Connected Daemon", value: status.connectedDaemon },
{ key: "Home", value: status.home }, { key: "Home", value: status.home },
{ key: "Listen", value: status.listen }, { key: "Listen", value: status.listen },
{ key: "Relay", value: status.relay },
{ key: "Hostname", value: status.hostname ?? "-" }, { key: "Hostname", value: status.hostname ?? "-" },
{ key: "PID", value: status.pid === null ? "-" : String(status.pid) }, { key: "PID", value: status.pid === null ? "-" : String(status.pid) },
{ key: "Started", value: status.startedAt ?? "-" }, { key: "Started", value: status.startedAt ?? "-" },
@@ -312,6 +314,12 @@ async function resolveDaemonNodeLabel(
return fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`; return fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`;
} }
function formatRelayStatus(state: ReturnType<typeof resolveLocalDaemonState>): string {
if (!state.relayEnabled) return "disabled";
const scheme = state.relayUseTls ? "wss" : "ws";
return `${scheme}://${state.relayEndpoint}`;
}
export type StatusResult = ListResult<StatusRow>; export type StatusResult = ListResult<StatusRow>;
export async function runStatusCommand( export async function runStatusCommand(
@@ -370,6 +378,7 @@ export async function runStatusCommand(
connectedDaemon, connectedDaemon,
home: state.home, home: state.home,
listen: state.listen, listen: state.listen,
relay: formatRelayStatus(state),
hostname: state.pidInfo?.hostname ?? null, hostname: state.pidInfo?.hostname ?? null,
pid: state.pidInfo?.pid ?? null, pid: state.pidInfo?.pid ?? null,
startedAt: state.pidInfo?.startedAt ?? null, startedAt: state.pidInfo?.startedAt ?? null,

View File

@@ -491,6 +491,7 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
relayEnabled: config.relayEnabled, relayEnabled: config.relayEnabled,
relayEndpoint: config.relayEndpoint, relayEndpoint: config.relayEndpoint,
relayPublicEndpoint: config.relayPublicEndpoint, relayPublicEndpoint: config.relayPublicEndpoint,
relayUseTls: config.relayUseTls,
appBaseUrl: config.appBaseUrl, appBaseUrl: config.appBaseUrl,
includeQr: true, includeQr: true,
}); });

View File

@@ -286,7 +286,7 @@ async function connectViaRelayOffer(
endpoint: offer.relay.endpoint, endpoint: offer.relay.endpoint,
serverId: offer.serverId, serverId: offer.serverId,
role: "client", role: "client",
useTls: shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint), useTls: offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint),
}); });
const client = new DaemonClient({ const client = new DaemonClient({

View File

@@ -9,6 +9,9 @@ import {
decrypt, decrypt,
} from "./crypto.js"; } 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"; const RELAY_BASE_URL = "wss://relay.paseo.sh";
async function withRetry<T>( async function withRetry<T>(

View File

@@ -191,6 +191,7 @@ export interface PaseoDaemonConfig {
relayEnabled?: boolean; relayEnabled?: boolean;
relayEndpoint?: string; relayEndpoint?: string;
relayPublicEndpoint?: string; relayPublicEndpoint?: string;
relayUseTls?: boolean;
appBaseUrl?: string; appBaseUrl?: string;
auth?: DaemonAuthConfig; auth?: DaemonAuthConfig;
openai?: PaseoOpenAIConfig; openai?: PaseoOpenAIConfig;
@@ -772,6 +773,7 @@ export async function createPaseoDaemon(
const relayEnabled = config.relayEnabled ?? true; const relayEnabled = config.relayEnabled ?? true;
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443"; const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint; const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
const relayUseTls = config.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443";
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh"; const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
if (boundListenTarget.type === "tcp") { if (boundListenTarget.type === "tcp") {
@@ -846,7 +848,7 @@ export async function createPaseoDaemon(
const offer = await createConnectionOfferV2({ const offer = await createConnectionOfferV2({
serverId, serverId,
daemonPublicKeyB64: daemonKeyPair.publicKeyB64, daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
relay: { endpoint: relayPublicEndpoint }, relay: { endpoint: relayPublicEndpoint, useTls: relayUseTls },
}); });
encodeOfferToFragmentUrl({ offer, appBaseUrl }); encodeOfferToFragmentUrl({ offer, appBaseUrl });
@@ -861,6 +863,7 @@ export async function createPaseoDaemon(
return wsServer.attachExternalSocket(ws, metadata); return wsServer.attachExternalSocket(ws, metadata);
}, },
relayEndpoint, relayEndpoint,
relayUseTls,
serverId, serverId,
daemonKeyPair: daemonKeyPair.keyPair, daemonKeyPair: daemonKeyPair.keyPair,
}); });

View File

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

View File

@@ -51,6 +51,7 @@ function normalizeLogEnv(value: string | undefined): string | undefined {
export type CliConfigOverrides = Partial<{ export type CliConfigOverrides = Partial<{
listen: string; listen: string;
relayEnabled: boolean; relayEnabled: boolean;
relayUseTls: boolean;
mcpEnabled: boolean; mcpEnabled: boolean;
mcpInjectIntoAgents: boolean; mcpInjectIntoAgents: boolean;
hostnames: HostnamesConfig; hostnames: HostnamesConfig;
@@ -139,12 +140,14 @@ interface ResolveRelayInput {
env: NodeJS.ProcessEnv; env: NodeJS.ProcessEnv;
persisted: ReturnType<typeof loadPersistedConfig>; persisted: ReturnType<typeof loadPersistedConfig>;
cliRelayEnabled: boolean | undefined; cliRelayEnabled: boolean | undefined;
cliRelayUseTls: boolean | undefined;
} }
interface ResolvedRelay { interface ResolvedRelay {
enabled: boolean; enabled: boolean;
endpoint: string; endpoint: string;
publicEndpoint: string; publicEndpoint: string;
useTls: boolean;
} }
function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay { function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
@@ -161,7 +164,12 @@ function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
input.env.PASEO_RELAY_PUBLIC_ENDPOINT ?? input.env.PASEO_RELAY_PUBLIC_ENDPOINT ??
input.persisted.daemon?.relay?.publicEndpoint ?? input.persisted.daemon?.relay?.publicEndpoint ??
endpoint; 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 { interface ResolvedVoiceLlm {
@@ -265,6 +273,7 @@ export function loadConfig(
env, env,
persisted, persisted,
cliRelayEnabled: options?.cli?.relayEnabled, cliRelayEnabled: options?.cli?.relayEnabled,
cliRelayUseTls: options?.cli?.relayUseTls,
}); });
const { openai, speech } = resolveSpeechConfig({ const { openai, speech } = resolveSpeechConfig({
@@ -293,6 +302,7 @@ export function loadConfig(
relayEnabled: relay.enabled, relayEnabled: relay.enabled,
relayEndpoint: relay.endpoint, relayEndpoint: relay.endpoint,
relayPublicEndpoint: relay.publicEndpoint, relayPublicEndpoint: relay.publicEndpoint,
relayUseTls: relay.useTls,
appBaseUrl, appBaseUrl,
auth: resolveAuthConfig(env, persisted), auth: resolveAuthConfig(env, persisted),
openai, openai,

View File

@@ -30,7 +30,7 @@ export function buildOfferEndpoints({ listenHost, port }: BuildOfferEndpointsArg
export async function createConnectionOfferV2(args: { export async function createConnectionOfferV2(args: {
serverId: string; serverId: string;
daemonPublicKeyB64: string; daemonPublicKeyB64: string;
relay: { endpoint: string }; relay: { endpoint: string; useTls?: boolean };
}): Promise<ConnectionOffer> { }): Promise<ConnectionOffer> {
return ConnectionOfferV2Schema.parse({ return ConnectionOfferV2Schema.parse({
v: 2, v: 2,

View File

@@ -40,6 +40,9 @@ function applyCliFlagOverrides(config: ReturnType<typeof loadConfig>): void {
if (process.argv.includes("--no-relay")) { if (process.argv.includes("--no-relay")) {
config.relayEnabled = false; config.relayEnabled = false;
} }
if (process.argv.includes("--relay-use-tls")) {
config.relayUseTls = true;
}
if (process.argv.includes("--no-mcp")) { if (process.argv.includes("--no-mcp")) {
config.mcpEnabled = false; config.mcpEnabled = false;
} }

View File

@@ -16,6 +16,7 @@ export async function generateLocalPairingOffer(args: {
relayEnabled?: boolean; relayEnabled?: boolean;
relayEndpoint?: string; relayEndpoint?: string;
relayPublicEndpoint?: string; relayPublicEndpoint?: string;
relayUseTls?: boolean;
appBaseUrl?: string; appBaseUrl?: string;
includeQr?: boolean; includeQr?: boolean;
logger?: Logger; logger?: Logger;
@@ -31,13 +32,14 @@ export async function generateLocalPairingOffer(args: {
const relayEndpoint = args.relayEndpoint ?? "relay.paseo.sh:443"; const relayEndpoint = args.relayEndpoint ?? "relay.paseo.sh:443";
const relayPublicEndpoint = args.relayPublicEndpoint ?? relayEndpoint; const relayPublicEndpoint = args.relayPublicEndpoint ?? relayEndpoint;
const relayUseTls = args.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443";
const appBaseUrl = args.appBaseUrl ?? "https://app.paseo.sh"; const appBaseUrl = args.appBaseUrl ?? "https://app.paseo.sh";
const serverId = getOrCreateServerId(args.paseoHome, { logger: args.logger }); const serverId = getOrCreateServerId(args.paseoHome, { logger: args.logger });
const daemonKeyPair = await loadOrCreateDaemonKeyPair(args.paseoHome, args.logger); const daemonKeyPair = await loadOrCreateDaemonKeyPair(args.paseoHome, args.logger);
const offer = await createConnectionOfferV2({ const offer = await createConnectionOfferV2({
serverId, serverId,
daemonPublicKeyB64: daemonKeyPair.publicKeyB64, daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
relay: { endpoint: relayPublicEndpoint }, relay: { endpoint: relayPublicEndpoint, useTls: relayUseTls },
}); });
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl }); const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });

View File

@@ -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", () => { describe("PersistedConfigSchema agent provider runtime settings", () => {
test("legacy append entries are skipped during migration", () => { test("legacy append entries are skipped during migration", () => {
const parsed = PersistedConfigSchema.parse({ const parsed = PersistedConfigSchema.parse({

View File

@@ -258,6 +258,7 @@ export const PersistedConfigSchema = z
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
endpoint: z.string().optional(), endpoint: z.string().optional(),
publicEndpoint: z.string().optional(), publicEndpoint: z.string().optional(),
useTls: z.boolean().optional(),
}) })
.strict() .strict()
.optional(), .optional(),

View File

@@ -133,6 +133,7 @@ describe("relay-transport control lifecycle", () => {
logger: logger as unknown as pino.Logger, logger: logger as unknown as pino.Logger,
attachSocket: async () => {}, attachSocket: async () => {},
relayEndpoint: "relay.paseo.sh:443", relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true,
serverId: "srv_test", serverId: "srv_test",
}); });
controllers.push(controller); controllers.push(controller);
@@ -155,6 +156,7 @@ describe("relay-transport control lifecycle", () => {
logger: logger as unknown as pino.Logger, logger: logger as unknown as pino.Logger,
attachSocket: async () => {}, attachSocket: async () => {},
relayEndpoint: "relay.paseo.sh:443", relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true,
serverId: "srv_test", serverId: "srv_test",
}); });
controllers.push(controller); controllers.push(controller);
@@ -177,6 +179,7 @@ describe("relay-transport control lifecycle", () => {
logger: logger as unknown as pino.Logger, logger: logger as unknown as pino.Logger,
attachSocket: async () => {}, attachSocket: async () => {},
relayEndpoint: "relay.paseo.sh:443", relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true,
serverId: "srv_test", serverId: "srv_test",
}); });
controllers.push(controller); controllers.push(controller);
@@ -198,6 +201,7 @@ describe("relay-transport control lifecycle", () => {
logger: logger as unknown as pino.Logger, logger: logger as unknown as pino.Logger,
attachSocket, attachSocket,
relayEndpoint: "relay.paseo.sh:443", relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true,
serverId: "srv_test", serverId: "srv_test",
}); });
controllers.push(controller); controllers.push(controller);
@@ -219,4 +223,24 @@ describe("relay-transport control lifecycle", () => {
externalSessionKey: "session:clt_test", 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\?/);
});
}); });

View File

@@ -8,16 +8,14 @@ import {
type Transport as RelayTransport, type Transport as RelayTransport,
type KeyPair, type KeyPair,
} from "@getpaseo/relay/e2ee"; } from "@getpaseo/relay/e2ee";
import { import { buildRelayWebSocketUrl } from "../shared/daemon-endpoints.js";
buildRelayWebSocketUrl,
shouldUseTlsForDefaultHostedRelay,
} from "../shared/daemon-endpoints.js";
import type { ExternalSocketMetadata } from "./websocket-server.js"; import type { ExternalSocketMetadata } from "./websocket-server.js";
interface RelayTransportOptions { interface RelayTransportOptions {
logger: pino.Logger; logger: pino.Logger;
attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>; attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>;
relayEndpoint: string; // "host:port" relayEndpoint: string; // "host:port"
relayUseTls: boolean;
serverId: string; serverId: string;
daemonKeyPair?: KeyPair; daemonKeyPair?: KeyPair;
} }
@@ -105,6 +103,7 @@ export function startRelayTransport({
logger, logger,
attachSocket, attachSocket,
relayEndpoint, relayEndpoint,
relayUseTls,
serverId, serverId,
daemonKeyPair, daemonKeyPair,
}: RelayTransportOptions): RelayTransportController { }: RelayTransportOptions): RelayTransportController {
@@ -158,7 +157,7 @@ export function startRelayTransport({
const connectionId = ++controlConnectionSeq; const connectionId = ++controlConnectionSeq;
const url = buildRelayWebSocketUrl({ const url = buildRelayWebSocketUrl({
endpoint: relayEndpoint, endpoint: relayEndpoint,
useTls: shouldUseTlsForDefaultHostedRelay(relayEndpoint), useTls: relayUseTls,
serverId, serverId,
role: "server", role: "server",
}); });
@@ -334,7 +333,7 @@ export function startRelayTransport({
const url = buildRelayWebSocketUrl({ const url = buildRelayWebSocketUrl({
endpoint: relayEndpoint, endpoint: relayEndpoint,
useTls: shouldUseTlsForDefaultHostedRelay(relayEndpoint), useTls: relayUseTls,
serverId, serverId,
role: "server", role: "server",
connectionId, connectionId,

View File

@@ -40,6 +40,39 @@ describe("connection offer", () => {
expect(parseConnectionOfferFromUrl(`https://app.paseo.sh/#offer=${encoded}`)).toEqual(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", () => { it("returns null when the URL has no offer fragment", () => {
expect(parseConnectionOfferFromUrl("https://app.paseo.sh/pair")).toBeNull(); expect(parseConnectionOfferFromUrl("https://app.paseo.sh/pair")).toBeNull();
}); });

View File

@@ -12,6 +12,7 @@ export const ConnectionOfferV2Schema = z.object({
daemonPublicKeyB64: z.string().min(1), daemonPublicKeyB64: z.string().min(1),
relay: z.object({ relay: z.object({
endpoint: z.string().min(1), endpoint: z.string().min(1),
useTls: z.boolean().optional().default(false),
}), }),
}); });

View File

@@ -4,6 +4,7 @@ import {
buildDaemonWebSocketUrl, buildDaemonWebSocketUrl,
buildRelayWebSocketUrl, buildRelayWebSocketUrl,
CURRENT_RELAY_PROTOCOL_VERSION, CURRENT_RELAY_PROTOCOL_VERSION,
extractHostPortFromWebSocketUrl,
normalizeRelayProtocolVersion, normalizeRelayProtocolVersion,
parseConnectionUri, parseConnectionUri,
serializeConnectionUri, serializeConnectionUri,
@@ -173,4 +174,17 @@ describe("relay websocket URLs", () => {
expect(url.protocol).toBe("wss:"); 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");
});
}); });

View File

@@ -198,6 +198,10 @@ export function buildRelayWebSocketUrl(params: {
return url.toString(); 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 { export function shouldUseTlsForDefaultHostedRelay(endpoint: string): boolean {
try { try {
return normalizeHostPort(endpoint) === DEFAULT_RELAY_ENDPOINT; return normalizeHostPort(endpoint) === DEFAULT_RELAY_ENDPOINT;