fix(server): honor X-Forwarded-Proto so daemon web UI auto-connects behind HTTPS reverse proxy (#1739)
* fix(server): honor forwarded proto for daemon web UI * fix(server): configure trusted daemon proxies * docs: document daemon web UI reverse proxy setup * docs: consolidate web UI proxy guidance
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
|
||||
interface InitialDaemonConnectionHint {
|
||||
listen: string;
|
||||
useTls: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function fetchDaemonWebUi(options: {
|
||||
port: number;
|
||||
headers?: Record<string, string>;
|
||||
}): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.get(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port: options.port,
|
||||
path: "/",
|
||||
headers: {
|
||||
host: `daemon.example.test:${options.port}`,
|
||||
...options.headers,
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", () => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`Expected 200 from web UI, got ${res.statusCode ?? 0}`));
|
||||
return;
|
||||
}
|
||||
resolve(Buffer.concat(chunks).toString("utf-8"));
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function readInjectedConnectionHint(html: string): InitialDaemonConnectionHint {
|
||||
const match = html.match(
|
||||
/window\.__PASEO_INITIAL_DAEMON_CONNECTION__=(?<json>\{[^<]+})<\/script>/,
|
||||
);
|
||||
if (!match?.groups?.json) {
|
||||
throw new Error("Missing initial daemon connection hint");
|
||||
}
|
||||
return JSON.parse(match.groups.json) as InitialDaemonConnectionHint;
|
||||
}
|
||||
|
||||
describe("daemon web UI bootstrap", () => {
|
||||
let tempRoot: string | null = null;
|
||||
let daemonHandle: TestPaseoDaemon | null = null;
|
||||
|
||||
async function createWebUiDist(): Promise<string> {
|
||||
tempRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-web-ui-"));
|
||||
const distDir = path.join(tempRoot, "dist");
|
||||
await mkdir(distDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(distDir, "index.html"),
|
||||
"<!DOCTYPE html><html><head></head><body>app</body></html>",
|
||||
);
|
||||
return distDir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await daemonHandle?.close();
|
||||
daemonHandle = null;
|
||||
if (tempRoot) {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
tempRoot = null;
|
||||
}
|
||||
});
|
||||
|
||||
test("injects a TLS initial connection hint only for HTTPS forwarded by a trusted proxy", async () => {
|
||||
const distDir = await createWebUiDist();
|
||||
|
||||
daemonHandle = await createTestPaseoDaemon({
|
||||
mcpEnabled: false,
|
||||
webUi: {
|
||||
enabled: true,
|
||||
distDir,
|
||||
},
|
||||
});
|
||||
|
||||
const httpHint = readInjectedConnectionHint(
|
||||
await fetchDaemonWebUi({ port: daemonHandle.port }),
|
||||
);
|
||||
const httpsHint = readInjectedConnectionHint(
|
||||
await fetchDaemonWebUi({
|
||||
port: daemonHandle.port,
|
||||
headers: { "x-forwarded-proto": "https" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(httpHint).toEqual({
|
||||
listen: `daemon.example.test:${daemonHandle.port}`,
|
||||
useTls: false,
|
||||
label: os.hostname(),
|
||||
});
|
||||
expect(httpsHint).toEqual({
|
||||
listen: `daemon.example.test:${daemonHandle.port}`,
|
||||
useTls: true,
|
||||
label: os.hostname(),
|
||||
});
|
||||
});
|
||||
|
||||
test("ignores forwarded HTTPS when proxy trust is disabled", async () => {
|
||||
const distDir = await createWebUiDist();
|
||||
|
||||
daemonHandle = await createTestPaseoDaemon({
|
||||
mcpEnabled: false,
|
||||
trustedProxies: [],
|
||||
webUi: {
|
||||
enabled: true,
|
||||
distDir,
|
||||
},
|
||||
});
|
||||
|
||||
const httpsHint = readInjectedConnectionHint(
|
||||
await fetchDaemonWebUi({
|
||||
port: daemonHandle.port,
|
||||
headers: { "x-forwarded-proto": "https" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(httpsHint).toEqual({
|
||||
listen: `daemon.example.test:${daemonHandle.port}`,
|
||||
useTls: false,
|
||||
label: os.hostname(),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -326,6 +326,7 @@ export interface PaseoDaemonConfig {
|
||||
corsAllowedOrigins: string[];
|
||||
allowedHosts?: HostnamesConfig;
|
||||
hostnames?: HostnamesConfig;
|
||||
trustedProxies?: true | string[];
|
||||
mcpEnabled?: boolean;
|
||||
mcpInjectIntoAgents?: boolean;
|
||||
autoArchiveAfterMerge?: boolean;
|
||||
@@ -423,6 +424,10 @@ function mountWebUi(app: express.Application, config: PaseoDaemonConfig, logger:
|
||||
);
|
||||
}
|
||||
|
||||
function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | string[] {
|
||||
return config.trustedProxies ?? ["loopback"];
|
||||
}
|
||||
|
||||
export async function createPaseoDaemon(
|
||||
config: PaseoDaemonConfig,
|
||||
rootLogger: Logger,
|
||||
@@ -487,6 +492,7 @@ export async function createPaseoDaemon(
|
||||
const listenTarget = parseListenString(config.listen);
|
||||
|
||||
const app = express();
|
||||
app.set("trust proxy", resolveExpressTrustProxySetting(config));
|
||||
let boundListenTarget: ListenTarget | null = null;
|
||||
let workspaceRegistry: FileBackedWorkspaceRegistry | null = null;
|
||||
const terminalManager = createConfiguredTerminalManager({
|
||||
|
||||
@@ -150,6 +150,56 @@ describe("daemon service proxy config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("daemon trusted proxy config", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
test("trusts loopback proxies by default", async () => {
|
||||
const home = await createPaseoHome({ version: 1 });
|
||||
|
||||
expect(loadConfig(home, { env: {} }).trustedProxies).toEqual(["loopback"]);
|
||||
});
|
||||
|
||||
test("loads trusted proxies from persisted config", async () => {
|
||||
const home = await createPaseoHome({
|
||||
version: 1,
|
||||
daemon: {
|
||||
trustedProxies: ["loopback", "10.0.0.0/8"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(loadConfig(home, { env: {} }).trustedProxies).toEqual(["loopback", "10.0.0.0/8"]);
|
||||
});
|
||||
|
||||
test("PASEO_TRUSTED_PROXIES overrides persisted config", async () => {
|
||||
const home = await createPaseoHome({
|
||||
version: 1,
|
||||
daemon: {
|
||||
trustedProxies: ["loopback"],
|
||||
},
|
||||
});
|
||||
|
||||
const config = loadConfig(home, {
|
||||
env: { PASEO_TRUSTED_PROXIES: "loopback,172.16.0.0/12" },
|
||||
});
|
||||
|
||||
expect(config.trustedProxies).toEqual(["loopback", "172.16.0.0/12"]);
|
||||
});
|
||||
|
||||
test("PASEO_TRUSTED_PROXIES supports explicit trust-all and trust-none modes", async () => {
|
||||
const trustAllHome = await createPaseoHome({ version: 1 });
|
||||
expect(
|
||||
loadConfig(trustAllHome, { env: { PASEO_TRUSTED_PROXIES: "true" } }).trustedProxies,
|
||||
).toBe(true);
|
||||
|
||||
const trustNoneHome = await createPaseoHome({ version: 1 });
|
||||
expect(
|
||||
loadConfig(trustNoneHome, { env: { PASEO_TRUSTED_PROXIES: "false" } }).trustedProxies,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("daemon worktree root config", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
|
||||
@@ -25,6 +25,7 @@ import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostn
|
||||
const DEFAULT_PORT = 6767;
|
||||
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
||||
const DEFAULT_APP_BASE_URL = "https://app.paseo.sh";
|
||||
const DEFAULT_TRUSTED_PROXIES = ["loopback"];
|
||||
|
||||
export function resolveBundledWebUiDistDir(moduleUrl: string | URL = import.meta.url): string {
|
||||
const moduleDir = path.dirname(fileURLToPath(moduleUrl));
|
||||
@@ -72,6 +73,8 @@ export type CliConfigOverrides = Partial<{
|
||||
hostnames: HostnamesConfig;
|
||||
}>;
|
||||
|
||||
type TrustedProxiesConfig = true | string[];
|
||||
|
||||
function resolveLogConfigFromEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
persisted: ReturnType<typeof loadPersistedConfig>,
|
||||
@@ -307,6 +310,37 @@ function resolveCorsAllowedOrigins(
|
||||
);
|
||||
}
|
||||
|
||||
function parseTrustedProxiesEnv(value: string | undefined): TrustedProxiesConfig | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = trimmed.toLowerCase();
|
||||
if (["1", "true", "yes", "on"].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (["0", "false", "no", "off"].includes(normalized)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.split(",")
|
||||
.map((proxy) => proxy.trim())
|
||||
.filter((proxy) => proxy.length > 0);
|
||||
}
|
||||
|
||||
function resolveTrustedProxiesConfig(
|
||||
env: NodeJS.ProcessEnv,
|
||||
persisted: ReturnType<typeof loadPersistedConfig>,
|
||||
): TrustedProxiesConfig {
|
||||
return (
|
||||
parseTrustedProxiesEnv(env.PASEO_TRUSTED_PROXIES) ??
|
||||
persisted.daemon?.trustedProxies ??
|
||||
DEFAULT_TRUSTED_PROXIES
|
||||
);
|
||||
}
|
||||
|
||||
// PASEO_LISTEN can be:
|
||||
// - host:port (TCP)
|
||||
// - /path/to/socket (Unix socket)
|
||||
@@ -374,6 +408,7 @@ function resolveStaticLoadConfigSettings(
|
||||
parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
|
||||
cli?.hostnames,
|
||||
]),
|
||||
trustedProxies: resolveTrustedProxiesConfig(env, persisted),
|
||||
appBaseUrl: env.PASEO_APP_BASE_URL ?? persisted.app?.baseUrl ?? DEFAULT_APP_BASE_URL,
|
||||
};
|
||||
}
|
||||
@@ -396,6 +431,7 @@ export function loadConfig(
|
||||
appendSystemPrompt,
|
||||
terminalProfiles,
|
||||
hostnames,
|
||||
trustedProxies,
|
||||
appBaseUrl,
|
||||
} = resolveStaticLoadConfigSettings(env, options?.cli, persisted);
|
||||
|
||||
@@ -425,6 +461,7 @@ export function loadConfig(
|
||||
worktreesRoot: resolveWorktreesRoot(paseoHome, persisted),
|
||||
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
|
||||
hostnames,
|
||||
trustedProxies,
|
||||
mcpEnabled,
|
||||
mcpInjectIntoAgents,
|
||||
autoArchiveAfterMerge,
|
||||
|
||||
@@ -63,6 +63,28 @@ describe("PersistedConfigSchema daemon relay config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PersistedConfigSchema daemon trusted proxy config", () => {
|
||||
test("accepts optional trusted proxy ranges", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
daemon: {
|
||||
trustedProxies: ["loopback", "172.16.0.0/12"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.daemon?.trustedProxies).toEqual(["loopback", "172.16.0.0/12"]);
|
||||
});
|
||||
|
||||
test("accepts explicit trust-all proxy config", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
daemon: {
|
||||
trustedProxies: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.daemon?.trustedProxies).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PersistedConfigSchema daemon web UI feature config", () => {
|
||||
test("accepts optional web UI enable flag and dist dir", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
|
||||
@@ -231,6 +231,7 @@ export const PersistedConfigSchema = z
|
||||
listen: z.string().optional(),
|
||||
hostnames: z.union([z.literal(true), z.array(z.string())]).optional(),
|
||||
allowedHosts: z.union([z.literal(true), z.array(z.string())]).optional(),
|
||||
trustedProxies: z.union([z.literal(true), z.array(z.string())]).optional(),
|
||||
mcp: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
|
||||
@@ -37,6 +37,8 @@ interface TestPaseoDaemonOptions {
|
||||
auth?: PaseoDaemonConfig["auth"];
|
||||
pushNotificationSender?: PushNotificationSender;
|
||||
serviceProxy?: PaseoDaemonConfig["serviceProxy"];
|
||||
webUi?: PaseoDaemonConfig["webUi"];
|
||||
trustedProxies?: PaseoDaemonConfig["trustedProxies"];
|
||||
}
|
||||
|
||||
export interface TestPaseoDaemon {
|
||||
@@ -168,6 +170,8 @@ async function prepareTestDaemonConfig(
|
||||
auth: options.auth,
|
||||
pushNotificationSender: options.pushNotificationSender,
|
||||
serviceProxy: options.serviceProxy,
|
||||
webUi: options.webUi,
|
||||
trustedProxies: options.trustedProxies,
|
||||
openai: options.openai,
|
||||
speech: options.speech,
|
||||
voiceLlmProvider: options.voiceLlmProvider ?? null,
|
||||
|
||||
Reference in New Issue
Block a user