Connect the self-hosted web app through standard-port proxies (#6638)

* fix(server): give the web UI connection hint an explicit port (#1)

injectConnectionHint built `listen` straight from the Host header, which
carries no port when the client used the scheme's default — exactly what a
browser sends to a proxy terminating TLS on 443. The app parses `listen` with
parseHostPort, which requires an explicit port, so connectionFromListen
returned null and bootstrapInitialConnectionHint bailed before probing,
silently. Boot then fell through to the localhost:6767 fallback, which is the
viewer's own machine rather than the daemon, and the app landed on the welcome
screen with no host at all.

Reaching the daemon directly never hit this: http://host:6767 puts the port in
the Host header. It only appears behind a reverse proxy on 443 or 80, such as a
Tailscale Ingress, and only in a browser profile with no host already stored.

Spell the scheme default out when the header omits it, leaving the IPv6 bracket
form intact.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(server): compute the web bootstrap port before its connection hint

Keep the default-port fix for reverse proxy bootstrap within the repository branching style.

Co-authored-by: Sian Lerk Lau <kiawin@gmail.com>

---------

Co-authored-by: Sian Lerk Lau <kiawin@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-11 20:51:51 +02:00
committed by GitHub
co-authored by Claude Opus 5 Sian Lerk Lau
parent 4bd31e1a2e
commit 0458988825
2 changed files with 63 additions and 1 deletions
@@ -134,6 +134,44 @@ describe("daemon web UI bootstrap", () => {
});
});
test("spells out the scheme default port when the Host header omits it", async () => {
const distDir = await createWebUiDist();
daemonHandle = await createTestPaseoDaemon({
mcpEnabled: false,
webUi: {
enabled: true,
distDir,
},
});
// A browser reaching a proxy that terminates TLS on 443 sends a Host header
// with no port at all. The app rejects a portless `listen`, so the daemon
// has to supply the default itself.
const httpsHint = readInjectedConnectionHint(
await fetchDaemonWebUi({
port: daemonHandle.port,
headers: { host: "paseo.example.test", "x-forwarded-proto": "https" },
}),
);
const httpHint = readInjectedConnectionHint(
await fetchDaemonWebUi({
port: daemonHandle.port,
headers: { host: "paseo.example.test" },
}),
);
const ipv6Hint = readInjectedConnectionHint(
await fetchDaemonWebUi({
port: daemonHandle.port,
headers: { host: "[::1]", "x-forwarded-proto": "https" },
}),
);
expect(httpsHint.listen).toBe("paseo.example.test:443");
expect(httpHint.listen).toBe("paseo.example.test:80");
expect(ipv6Hint.listen).toBe("[::1]:443");
});
test("ignores forwarded HTTPS when proxy trust is disabled", async () => {
const distDir = await createWebUiDist();
+25 -1
View File
@@ -250,6 +250,22 @@ function serializeInlineScriptJson(value: unknown): string {
.replace(/&/g, "\\u0026");
}
/**
* A Host header carries no port when the client used the scheme's default one,
* which is what every browser sends to a proxy terminating TLS on 443. The app
* parses `listen` with parseHostPort, which requires an explicit port and
* rejects the value outright, so the hint has to spell the port out.
*/
function hasExplicitPort(host: string): boolean {
if (host.startsWith("[")) {
// IPv6 literals are bracketed: [::1] has no port, [::1]:443 does.
const end = host.indexOf("]");
return end !== -1 && /^:\d{1,5}$/.test(host.slice(end + 1));
}
const colon = host.indexOf(":");
return colon !== -1 && colon === host.lastIndexOf(":") && /^\d{1,5}$/.test(host.slice(colon + 1));
}
function injectConnectionHint(
html: string,
req: Parameters<RequestHandler>[0],
@@ -257,8 +273,16 @@ function injectConnectionHint(
): string {
const host = typeof req.headers.host === "string" ? req.headers.host : "";
const useTls = req.protocol === "https";
let listen = host;
if (host && !hasExplicitPort(host)) {
let defaultPort = 80;
if (useTls) {
defaultPort = 443;
}
listen = `${host}:${defaultPort}`;
}
const hint = {
listen: host,
listen,
useTls,
label,
};