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:
co-authored by
Mohamed Boudra
parent
ee44b536f5
commit
338a41991d
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user