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
+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,
});
}
}