feat: add independent TLS control for relay public endpoint (#1045)

* feat: add independent TLS control for relay public endpoint

Add PASEO_RELAY_PUBLIC_USE_TLS env var and publicUseTls persisted
config to separately control TLS for the client→relay pairing offer,
falling back to relayUseTls when unset. The daemon→relay connection
continues to use PASEO_RELAY_USE_TLS alone.

Enables self-hosting behind a TLS-terminating reverse proxy where
the internal path is plain ws:// but public clients need wss://.

* fix: use public relay TLS in daemon status

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Zexin Yuan
2026-05-17 23:00:42 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent ee44b536f5
commit 338a41991d
12 changed files with 131 additions and 15 deletions
+1 -1
View File
@@ -107,7 +107,7 @@ Enables remote access when the daemon is behind a firewall.
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
- 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`
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `PASEO_RELAY_PUBLIC_USE_TLS`
See [SECURITY.md](../SECURITY.md) for the full threat model.
+1 -1
View File
@@ -140,7 +140,7 @@ Single file, validated with `PersistedConfigSchema`.
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
mcp: { enabled: boolean, injectIntoAgents: boolean },
cors: { allowedOrigins: string[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean },
auth: { password: string } // bcrypt hash, optional
},
app: {
@@ -1,9 +1,13 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
type DaemonLaunchRuntime,
type DetachedDaemonProcess,
resolveLocalDaemonState,
startLocalDaemonDetached,
startLocalDaemonForeground,
} from "./local-daemon.js";
@@ -64,6 +68,17 @@ class FakeDaemonRuntime implements DaemonLaunchRuntime {
}
}
const tempRoots: string[] = [];
async function createPaseoHome(config: unknown): Promise<string> {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-local-daemon-"));
tempRoots.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;
}
function expectSupervisorLaunch(argv: string[]): void {
const joined = argv.join(" ");
expect(joined).toContain("supervisor-entrypoint");
@@ -78,6 +93,12 @@ describe("local daemon launch supervision", () => {
vi.useRealTimers();
});
afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
const runtime = new FakeDaemonRuntime();
@@ -131,4 +152,24 @@ describe("local daemon launch supervision", () => {
expect(launch?.args).toContain("--relay-use-tls");
expect(launch?.options?.env?.PASEO_RELAY_USE_TLS).toBe("true");
});
test("local daemon state keeps public relay TLS separate from daemon relay TLS", async () => {
const home = await createPaseoHome({
version: 1,
daemon: {
relay: {
endpoint: "10.0.0.5:51185",
publicEndpoint: "paseo.example.com",
useTls: false,
publicUseTls: true,
},
},
});
const state = resolveLocalDaemonState({ home });
expect(state.relayEndpoint).toBe("paseo.example.com");
expect(state.relayUseTls).toBe(false);
expect(state.relayPublicUseTls).toBe(true);
});
});
@@ -33,6 +33,7 @@ export interface LocalDaemonState {
relayEnabled: boolean;
relayEndpoint: string;
relayUseTls: boolean;
relayPublicUseTls: boolean;
logPath: string;
pidPath: string;
pidInfo: LocalDaemonPidInfo | null;
@@ -409,6 +410,7 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD
relayEnabled: config.relayEnabled ?? true,
relayEndpoint: config.relayPublicEndpoint ?? config.relayEndpoint ?? "relay.paseo.sh:443",
relayUseTls: config.relayUseTls ?? false,
relayPublicUseTls: config.relayPublicUseTls ?? config.relayUseTls ?? false,
logPath,
pidPath,
pidInfo,
+1
View File
@@ -29,6 +29,7 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
relayEndpoint: config.relayEndpoint,
relayPublicEndpoint: config.relayPublicEndpoint,
relayUseTls: config.relayUseTls,
relayPublicUseTls: config.relayPublicUseTls,
appBaseUrl: config.appBaseUrl,
includeQr: true,
});
+1 -1
View File
@@ -316,7 +316,7 @@ async function resolveDaemonNodeLabel(
function formatRelayStatus(state: ReturnType<typeof resolveLocalDaemonState>): string {
if (!state.relayEnabled) return "disabled";
const scheme = state.relayUseTls ? "wss" : "ws";
const scheme = state.relayPublicUseTls ? "wss" : "ws";
return `${scheme}://${state.relayEndpoint}`;
}
+1
View File
@@ -492,6 +492,7 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
relayEndpoint: config.relayEndpoint,
relayPublicEndpoint: config.relayPublicEndpoint,
relayUseTls: config.relayUseTls,
relayPublicUseTls: config.relayPublicUseTls,
appBaseUrl: config.appBaseUrl,
includeQr: true,
});
+9 -2
View File
@@ -239,6 +239,7 @@ export interface PaseoDaemonConfig {
relayEndpoint?: string;
relayPublicEndpoint?: string;
relayUseTls?: boolean;
relayPublicUseTls?: boolean;
appBaseUrl?: string;
auth?: DaemonAuthConfig;
openai?: PaseoOpenAIConfig;
@@ -300,7 +301,9 @@ export async function createPaseoDaemon(
const staticDir = config.staticDir;
const downloadTokenTtlMs = config.downloadTokenTtlMs ?? 60000;
const downloadTokenStore = new DownloadTokenStore({ ttlMs: downloadTokenTtlMs });
const downloadTokenStore = new DownloadTokenStore({
ttlMs: downloadTokenTtlMs,
});
const listenTarget = parseListenString(config.listen);
@@ -863,6 +866,7 @@ export async function createPaseoDaemon(
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
const relayUseTls = config.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443";
const relayPublicUseTls = config.relayPublicUseTls ?? relayUseTls;
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
if (boundListenTarget.type === "tcp") {
@@ -938,7 +942,10 @@ export async function createPaseoDaemon(
const offer = await createConnectionOfferV2({
serverId,
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
relay: { endpoint: relayPublicEndpoint, useTls: relayUseTls },
relay: {
endpoint: relayPublicEndpoint,
useTls: relayPublicUseTls,
},
});
encodeOfferToFragmentUrl({ offer, appBaseUrl });
@@ -44,7 +44,42 @@ describe("daemon relay config", () => {
});
expect(loadConfig(envHome, { env: { PASEO_RELAY_USE_TLS: "true" } }).relayUseTls).toBe(true);
const hostedHome = await createPaseoHome({ version: 1, daemon: { relay: {} } });
const hostedHome = await createPaseoHome({
version: 1,
daemon: { relay: {} },
});
expect(loadConfig(hostedHome, { env: {} }).relayUseTls).toBe(true);
});
test("relayPublicUseTls falls back to relayUseTls when unset", async () => {
const home = await createPaseoHome({ version: 1, daemon: { relay: {} } });
// Default: both true (hosted relay)
expect(loadConfig(home, { env: {} }).relayPublicUseTls).toBe(true);
});
test("PASEO_RELAY_PUBLIC_USE_TLS overrides relayUseTls for public side", async () => {
const home = await createPaseoHome({ version: 1, daemon: { relay: {} } });
const config = loadConfig(home, {
env: { PASEO_RELAY_USE_TLS: "false", PASEO_RELAY_PUBLIC_USE_TLS: "true" },
});
expect(config.relayUseTls).toBe(false);
expect(config.relayPublicUseTls).toBe(true);
});
test("relayPublicUseTls falls back to relayUseTls when only PASEO_RELAY_USE_TLS is set", async () => {
const home = await createPaseoHome({ version: 1, daemon: { relay: {} } });
const config = loadConfig(home, { env: { PASEO_RELAY_USE_TLS: "false" } });
expect(config.relayUseTls).toBe(false);
expect(config.relayPublicUseTls).toBe(false);
});
test("persisted publicUseTls overrides relayUseTls fallback", async () => {
const home = await createPaseoHome({
version: 1,
daemon: { relay: { useTls: false, publicUseTls: true } },
});
const config = loadConfig(home, { env: {} });
expect(config.relayUseTls).toBe(false);
expect(config.relayPublicUseTls).toBe(true);
});
});
+24 -4
View File
@@ -148,6 +148,18 @@ interface ResolvedRelay {
endpoint: string;
publicEndpoint: string;
useTls: boolean;
publicUseTls: boolean;
}
function resolveTlsFromEnv(
envValue: string | undefined,
persistedValue: boolean | undefined,
fallback: boolean,
): boolean {
if (envValue !== undefined) {
return parseBooleanEnv(envValue) ?? false;
}
return persistedValue ?? fallback;
}
function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
@@ -166,10 +178,17 @@ function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
endpoint;
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 };
resolveTlsFromEnv(
input.env.PASEO_RELAY_USE_TLS,
input.persisted.daemon?.relay?.useTls,
endpoint === DEFAULT_RELAY_ENDPOINT,
);
const publicUseTls = resolveTlsFromEnv(
input.env.PASEO_RELAY_PUBLIC_USE_TLS,
input.persisted.daemon?.relay?.publicUseTls,
useTls,
);
return { enabled, endpoint, publicEndpoint, useTls, publicUseTls };
}
interface ResolvedVoiceLlm {
@@ -305,6 +324,7 @@ export function loadConfig(
relayEndpoint: relay.endpoint,
relayPublicEndpoint: relay.publicEndpoint,
relayUseTls: relay.useTls,
relayPublicUseTls: relay.publicUseTls,
appBaseUrl,
auth: resolveAuthConfig(env, persisted),
openai,
+3 -1
View File
@@ -17,6 +17,7 @@ export async function generateLocalPairingOffer(args: {
relayEndpoint?: string;
relayPublicEndpoint?: string;
relayUseTls?: boolean;
relayPublicUseTls?: boolean;
appBaseUrl?: string;
includeQr?: boolean;
logger?: Logger;
@@ -33,13 +34,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 relayPublicUseTls = args.relayPublicUseTls ?? relayUseTls;
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, useTls: relayUseTls },
relay: { endpoint: relayPublicEndpoint, useTls: relayPublicUseTls },
});
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
+10 -3
View File
@@ -263,6 +263,7 @@ export const PersistedConfigSchema = z
endpoint: z.string().optional(),
publicEndpoint: z.string().optional(),
useTls: z.boolean().optional(),
publicUseTls: z.boolean().optional(),
})
.strict()
.optional(),
@@ -387,7 +388,9 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per
raw = readFileSync(configPath, "utf-8");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`[Config] Failed to read ${configPath}: ${message}`, { cause: err });
throw new Error(`[Config] Failed to read ${configPath}: ${message}`, {
cause: err,
});
}
let parsed: unknown;
@@ -395,7 +398,9 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per
parsed = JSON.parse(raw);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`[Config] Invalid JSON in ${configPath}: ${message}`, { cause: err });
throw new Error(`[Config] Invalid JSON in ${configPath}: ${message}`, {
cause: err,
});
}
const migrated = stripDeprecatedLocalSpeechConfigFields(parsed);
@@ -432,6 +437,8 @@ export function savePersistedConfig(
log?.info(`Saved to ${configPath}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`[Config] Failed to write ${configPath}: ${message}`, { cause: err });
throw new Error(`[Config] Failed to write ${configPath}: ${message}`, {
cause: err,
});
}
}