Repository navigation
Description
Description
Accessing the daemon web UI through a reverse proxy (e.g. nginx, Caddy, tunnel services) fails because the WebSocket connection falls back to localhost:6767 instead of using the proxy URL.
Steps to Reproduce
- Start daemon with web UI on a custom port
- Expose the daemon port through a reverse proxy
- Access the proxy URL in a browser remotely
Expected
Web UI loads and WebSocket connects through the proxy URL.
Actual
The WebSocket attempts to connect to localhost and fails because:
- The client-side
connectionFromListen()callsnormalizeHostPort()which requireshost:portformat — when the proxy Host header omits the standard port, parsing throws andconnectionFromListenreturns null - The system falls back to
bootstrapDefaultLocalhost()→localhost:6767, which is unreachable from a remote browser
Additionally, users hitting the hostname validation error ({"error":"Invalid Host header"}) may try setting the Host header to 0.0.0.0:PORT, which normalizeLoopbackToLocalhost then converts to localhost:PORT — still wrong for remote access.
Root Cause
In packages/server/src/server/web-ui.ts → injectConnectionHint():
const host = req.headers.host; // proxy hostname without port
const hint = { listen: host, useTls, label };The hint is passed to connectionFromListen() which requires host:port format. Without a port, it returns null and the default localhost fallback is used.
Proposed Fix
In injectConnectionHint, when the Host header lacks a port, default to the protocol-appropriate standard port:
const listen = host.includes(":") ? host : `${host}:${useTls ? 443 : 80}`;
const hint = { listen, useTls, label };This makes the hint valid for connectionFromListen, and the client correctly builds the WebSocket URL through the proxy (e.g. wss://proxy.example.com:443/ws).
Additional Notes
- The
hostnamesconfig key for allowing external hostnames is under-documented — users hitting the "Invalid Host header" error are unaware ofPASEO_HOSTNAMES/config.jsondaemon.hostnames. - For reverse proxy scenarios with dynamic hostnames (e.g. tunnel services), a wildcard or pattern-match hostname allowlist would be helpful.
Activity
paseo-bot commentedon Jul 31, 2026
Keeping this open as a current web bug.
The failure is still present on
main: the daemon injects the requestHostheader verbatim, while the client rejects a TCP listen address without an explicit port. That makes a standard-port reverse proxy whoseHostheader is only a hostname fall through to the local bootstrap path.PR #1843 addressed this report but was closed without merging. The newer PR #2618 reproduces the same failure through the daemon web UI and proposes a tested fix, but it is still open and unmerged. Until a fix lands, preserving an explicit port in the forwarded
Hostheader or adding the correct host manually is the workaround.This is limited to the web UI behind affected reverse-proxy configurations, so it is tracked as
bug/p2/platform:web.paseo-bot commentedon Aug 1, 2026
Imported from Discord.
shasderias, 2026-07-22 14:55:17 UTC
If Paseo's web UI (
--web-ui) is being served on a standard HTTP/HTTPS (80/443) public (i.e. the port that the user sees) port (e.g. behind a HTTPS proxy):example.com)example.com:443)When this happens, the UI loads, but I need to do (A) to get a working Paseo instance.
(A) If we establish a direct connection and specify an explicit port, that works.
(B) If Paseo is served on a non-standard public port (e.g. 7123), the Host header contains the port component and things also work as expected.
I've tested (A) and (B) by hand.
Below is LLM generated analysis (that I've roughly verified by hand) that might help shortcut debugging.
The server copies the raw
Hostheader into the connection hint:packages/server/src/server/web-ui.ts:253-264The client passes
hint.listentoconnectionFromListen(). If parsing returnsnull, it abandons the hint without logging an error:packages/app/src/runtime/host-runtime.ts:1542-1548connectionFromListen()catches endpoint parsing errors and returnsnull:packages/app/src/types/host-connection.ts:213-253The underlying parser requires
host:port:packages/protocol/src/daemon-endpoints.ts:52-82In particular:
shasderias, 2026-07-29 17:38:45 UTC
I've attempted a PR here, hope it's useful. #2618
mogul commentedon Sep 30, 2026
Same normalization, a different way to lose
Your report is the reverse-proxy case: the
Hostheader lacks a port,connectionFromListenreturnsnull, and the client falls back tobootstrapDefaultLocalhost()→localhost:6767.The variant I hit is the container / VM published-port case, and the hint is not rejected — it is accepted and then rewritten into a name. Running the daemon inside a microsandbox VM with the guest port published to host loopback, the served page correctly advertises the IPv4 literal:
That is right — it is the address the page was loaded from. Following v0.10.2:
packages/server/src/server/web-ui.ts:253—injectConnectionHintsetslisten: req.headers.host→127.0.0.1:6767packages/app/src/runtime/host-runtime.ts:1685—bootstrapInitialConnectionHint→connectionFromListen(hint.listen)packages/app/src/types/host-connection.ts:346—normalizeLoopbackToLocalhost(normalizeHostPort(...))packages/protocol/src/daemon-endpoints.ts:196— maps127.0.0.1→localhostStored connection becomes
{ id: "direct:localhost:6767", type: "directTcp", endpoint: "localhost:6767" }, andbuildDaemonWebSocketUrldialsws://localhost:6767/ws.And the address bar cannot escape it. Every loopback form collapses to the same key — I ran the v0.10.2 function over each:
normalizeStoredConnection(host-connection.ts:436) re-applies it on every registry load, so entering127.0.0.1:6767by hand in the UI does not survive a reload either. There is no way, through the UI or the address bar, to hold an IPv4-literal direct connection.Why
localhostis not a safe synonym::1resolving first against an IPv4-only listener does not make the connection hang. Measured against the real IPv4-only listener:::1literalECONNREFUSEDin 9ms — fails fastcurl -6 localhost(no fallback)ECONNREFUSEDin 0ms — fails fastcurl localhost(fallback allowed)remote_ip=127.0.0.1remote_ip=127.0.0.1in 9msAn unbound loopback port answers with an immediate RST. Browsers implement Happy Eyeballs (RFC 8305), so they race the families and reach IPv4 anyway.
::1-first, on its own, is not a failure.The real problem is that a name selects between listeners, and the other family can be occupied. microsandbox publishes IPv4-only by default (
default_host_bind()→Ipv4Addr::LOCALHOST, andTcpSocket::new_v4()for an IPv4 bind, with no dual-stack companion). An IPv4 bind on*:6767and an unrelated[::1]:6767bind do not conflict — both listen at once. Demonstrated:So pinning the stored endpoint to a name means the daemon you connect to depends on resolver order and on what else happens to be bound — not on the address the daemon advertised. In a container/VM setup where ports get republished and forwarders can be left behind, "something else on the other family" is a realistic state, not a contrived one.
This also matters for a class of bug I hit downstream: I spent hours on a session that connected to a stale daemon on the same port. Identity was the only signal that differed, and the normalization had removed the one addressing detail that could have pinned it.
Two suggestions, smallest first
1. Do not normalize away a loopback literal that was explicitly advertised. The injected hint is derived from a request that just succeeded — it is the one endpoint known reachable. Collapsing it to a name reintroduces a selection step the hint had already resolved:
If dedupe against the built-in
localhostkey is why the normalization exists, the dedupe could key on the post-helloserverIdinstead — which is already how identity is established.2. Surface the address actually connected to. Whatever the endpoint, showing the resolved peer (or naming it on failure) makes
::1vs127.0.0.1visible rather than inferred. That alone would have shortened my investigation considerably.Environment
web-ui.tsis byte-identical, andnormalizeLoopbackToLocalhostis byte-identical)PASEO_LISTEN=0.0.0.0:6767, published to host127.0.0.1:6767GSA-TTS engineer. Investigation AI-assisted (OpenCode); source claims human-verified against v0.10.2, and the corrected claim above was measured rather than reasoned.
paseo-bot commentedon Oct 1, 2026
@mogul I've filed your case as its own issue, #5807, since the symptom differs from this one: the UI connects to a different process instead of failing to connect. Nothing needed from you; updates go there.
paseo-bot commentedon Oct 11, 2026
I reproduced the standard-port proxy failure in a fresh browser: the web UI showed the welcome screen and attempted localhost:6767. With #6638, the same browser path connects through the proxy URL. The regression test fails without the fix and passes with it.
The browser check used HTTP on port 80. HTTPS forwarding and port 443, IPv6, and explicit ports passed the real-daemon tests. Actual TLS browser termination was not exercised. The separate loopback-address case remains tracked in #5807.
#6638 carries forward the contributor's #3832 with credit and a small branching-style correction. It is open with CI running; updates go on that pull request.
paseo-bot commentedon Oct 11, 2026
The standard-port proxy fix in #6638 now has green CI. In the verified fresh-browser flow, the page that previously attempted localhost:6767 connects through the proxy URL, and the regression test fails without the fix.
The fix is still unmerged. Updates remain on #6638.