diff --git a/docs/architecture.md b/docs/architecture.md index 44f84651a..502854d9a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/data-model.md b/docs/data-model.md index 55107f2fe..b92acfa94 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -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: { diff --git a/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts b/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts index 931ea80cf..9b51eec51 100644 --- a/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts +++ b/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts @@ -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 { + 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); + }); }); diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index 4fbb97770..943752e4e 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -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, diff --git a/packages/cli/src/commands/daemon/pair.ts b/packages/cli/src/commands/daemon/pair.ts index 4f228110b..14aced0cc 100644 --- a/packages/cli/src/commands/daemon/pair.ts +++ b/packages/cli/src/commands/daemon/pair.ts @@ -29,6 +29,7 @@ export async function runPairCommand(options: PairOptions): Promise { relayEndpoint: config.relayEndpoint, relayPublicEndpoint: config.relayPublicEndpoint, relayUseTls: config.relayUseTls, + relayPublicUseTls: config.relayPublicUseTls, appBaseUrl: config.appBaseUrl, includeQr: true, }); diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index 987b073d1..dbc41c423 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -316,7 +316,7 @@ async function resolveDaemonNodeLabel( function formatRelayStatus(state: ReturnType): string { if (!state.relayEnabled) return "disabled"; - const scheme = state.relayUseTls ? "wss" : "ws"; + const scheme = state.relayPublicUseTls ? "wss" : "ws"; return `${scheme}://${state.relayEndpoint}`; } diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index 4eb4a04f5..b581509ec 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -492,6 +492,7 @@ export async function runOnboard(options: OnboardOptions): Promise { relayEndpoint: config.relayEndpoint, relayPublicEndpoint: config.relayPublicEndpoint, relayUseTls: config.relayUseTls, + relayPublicUseTls: config.relayPublicUseTls, appBaseUrl: config.appBaseUrl, includeQr: true, }); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index a8ae12c6c..a4554bb5e 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -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 }); diff --git a/packages/server/src/server/config-relay.test.ts b/packages/server/src/server/config-relay.test.ts index 45cd9f358..70e8a9c83 100644 --- a/packages/server/src/server/config-relay.test.ts +++ b/packages/server/src/server/config-relay.test.ts @@ -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); + }); }); diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 9c620a9c9..f5b2fe09a 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -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, diff --git a/packages/server/src/server/pairing-offer.ts b/packages/server/src/server/pairing-offer.ts index b9b0aa185..c8f038e88 100644 --- a/packages/server/src/server/pairing-offer.ts +++ b/packages/server/src/server/pairing-offer.ts @@ -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 }); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 825cb780c..dc5dfcec6 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -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, + }); } }