Skip to content

feat(websocket): share port with HTTP handler#3509

Open
lidel wants to merge 5 commits into
masterfrom
feat/ws-fallback-http-handler
Open

feat(websocket): share port with HTTP handler#3509
lidel wants to merge 5 commits into
masterfrom
feat/ws-fallback-http-handler

Conversation

@lidel
Copy link
Copy Markdown
Member

@lidel lidel commented May 15, 2026

Adds websocket.WithFallbackHTTPHandler(http.Handler) so a /ws or /tls/ws listener answers non-WebSocket requests with the supplied handler instead of 404, on the same TCP port.

👉 Opt-in, backward-compatible, default behaviour is unchanged, but go-libp2p gains important regression tests.

Two uses

  • Share a libp2p WebSocket port with an ordinary HTTPS site. The port looks like a normal HTTPS endpoint that also accepts WebSocket upgrades. Libp2p on port 443 with regular website becomes way harder to censor.
  • Expose kubo's trustless gateway on the AutoTLS-secured /tls/ws port. HTTP retrieval clients (boxo/bitswap/network/httpnet) fetch blocks directly over HTTPS using the AutoTLS cert. Improved interop of IPFS stack.

Routing

WebSocket upgrades go to libp2p as before; everything else goes to the handler. The handler runs over HTTP/1.1 and HTTP/2 on TLS listeners (ALPN inside http.Server.ServeTLS) and over HTTP/1.1 and HTTP/2 cleartext on plaintext listeners (h2c.NewHandler). Also in tcpreuse: IsHTTP matches the PRI h2c preface so it routes to the HTTP demux branch.

tpt, err := websocket.New(upgrader, rcmgr, sharedTCP,
    websocket.WithTLSConfig(tlsConfig),
    websocket.WithFallbackHTTPHandler(httpHandler),
)

Important

End-to-end example in ipfs/kubo#11333, which shares kubo's AutoTLS-secured /tls/ws and a /tls/http endpoint on one Let's Encrypt cert:

Regression tests

👉 tests added here protect ecosystem from silently breaking /ws.

gorilla/websocket has no HTTP/2 / RFC 8441 server support today. WebSocket-over-h2 needs extended CONNECT (:method=CONNECT, :protocol=websocket); gorilla's Upgrader.Upgrade still reads HTTP/1.1 hijack-based headers, and Go's h2 stack does not advertise SETTINGS_ENABLE_CONNECT_PROTOCOL (gated on process-global GODEBUG=http2xconnect=1; per-server API tracked in golang/go#71128). Two tripwires added by this PR catch any future upstream shift so go-libp2p maintainers can deliberately embrace WS-over-h2 or pin the dispatch:

  • TestFallbackHTTPHandler_HTTP2NeverReachesWSPath: every HTTP/2 request reaches the fallback handler, never the WebSocket hijack path. Fails if a future gorilla release extends IsWebSocketUpgrade to recognise ext-CONNECT.
  • TestModernBrowserWSSFlow: the server's initial h2 SETTINGS frame omits SETTINGS_ENABLE_CONNECT_PROTOCOL. Browsers rely on its absence to fall back to HTTP/1.1 for wss://; the test fails if Go ever flips this flag on by default.

Plus a concurrency pin for the new fallback path:

  • TestFallbackHTTPHandler_KeepAlive + TestNegotiatingConnUnwrapConcurrent: the handshake-timer is disarmed once a fallback request arrives, safely under concurrent HTTP/2 streams sharing one *negotiatingConn.

lidel added 2 commits May 15, 2026 23:53
Add WithFallbackHTTPHandler so /ws and /tls/ws listeners can serve a
caller-supplied http.Handler for every request that is not a WebSocket
upgrade. Lets a libp2p node share its TCP port with a regular
HTTP/HTTPS site (censorship resistance, single-port deployments,
HTTP-native retrieval endpoints next to libp2p WebSockets).

- option + listener wiring with handshake-timer disarm so HTTP/2
  streams and HTTP/1.1 keep-alive survive past the 15s default
- HTTP/2 over TLS via http.Server.ServeTLS ALPN; HTTP/2 cleartext
  via h2c.NewHandler wrap when the listener is plaintext
- tcpreuse IsHTTP gains PRI so the h2c connection preface routes
  to the HTTP demux branch
- transport stays interop-maximal: any HTTP version on any listener;
  application-level version policy is the wrapped handler's call
- 9 fallback handler tests, modern-browser wss:// flow with raw h2
  framer SETTINGS check, h2 ext-CONNECT regression, h2 keep-alive
  disarm
Two test handlers intentionally ignore one of (w, r); revive flagged them
in PR #3509's go-check job.
@lidel lidel marked this pull request as ready for review May 16, 2026 20:30
@lidel lidel requested review from MarcoPolo, aschmahmann and sukunrt and removed request for aschmahmann May 16, 2026 20:31
lidel added 2 commits May 19, 2026 15:02
negotiatingConn.Unwrap read and wrote stopClose without synchronization
and treated context.AfterFunc's stop returning false as a timeout. Under
HTTP/2 multiplexing, several streams on one TCP connection share a
single *negotiatingConn, so concurrent Unwrap calls raced on stopClose
and the losers silently dropped their requests.

Wrap the disarm in sync.Once and record the AfterFunc outcome so all
callers observe the same result. Add a unit test on the disarm contract
and an integration test that drives concurrent h2 streams through the
fallback handler, both run under -race.
@lidel
Copy link
Copy Markdown
Member Author

lidel commented May 19, 2026

@MarcoPolo @sukunrt kind ping to put this on the top of review queue.

The foundations of /ws are VERY shaky, and this PR adds necessary regression tests (see last section in description).

It also adds opt-in websocket.WithFallbackHTTPHandler(httpHandler) to reduce our dependency on /ws and benefit from HTTP/2 multiplexing in IPFS ecosystem, but I'm pinging you because of how shaky /ws is and asking if we could prioritize this, so we have regression tests running on CI 🙏 (we really, really dont want libp2p.direct peers to break silently when upstream library finally adds h2 support).

lidel added a commit to ipfs/kubo that referenced this pull request May 27, 2026
Pulls in Boxo v0.40.0, go-libp2p-kad-dht v0.40.0, cheggaaa/pb v3, and
the dag --local-only / migration fetcher work from master.

Dep conflicts resolved by keeping our HTTPProvider-required pins on top
of master's baseline:

- certmagic v0.25.3 (pulled in by p2p-forge v0.9.0)
- p2p-forge v0.9.0
- go-libp2p v0.48.1-0.20260515215300-a72c0588b088 (pin for libp2p/go-libp2p#3509,
  still open; needed for websocket.WithFallbackHTTPHandler)

Other transitive bumps follow from `make mod_tidy`. AutoTLS canary +
HTTPProvider CLI tests pass on the merged tree.
hostPort := startWSSListener(t, handler)

// Force ALPN to h2 only, so the connection MUST be HTTP/2 or fail.
resp, err := httpsClient([]string{"h2"}).Get("https://" + hostPort + "/")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you need an extended connect request here to make it fail if gorilla/ws implements h2 websocket support.

Copy link
Copy Markdown
Member Author

@lidel lidel May 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch @sukunrt, you're right. A plain GET never trips IsWebSocketUpgrade. Improved and it now sends a real extended CONNECT.

One wrinkle worth noting: the bundled h2 server resets the stream before the handler ever sees an extended CONNECT unless it advertises ENABLE_CONNECT_PROTOCOL (golang/go#71128), which is gated by GODEBUG=http2xconnect=1 and read once at startup. So the test re-execs itself in a child with that set, sends the extended CONNECT over raw frames, and asserts it reaches the fallback handler. Confirmed it goes red if dispatch ever routes extended CONNECT to the WS upgrade path.

See 9dce1ca. Not a fan, but unsure if this could be done in a better way, given golang limitations.

Strengthen the HTTP/2-never-reaches-WS test so it actually proves the
invariant it documents. A plain h2 GET can never be a WebSocket upgrade,
so the old test would not have caught a gorilla/websocket bump that
teaches IsWebSocketUpgrade about RFC 8441 extended CONNECT.

- send a real extended CONNECT (:method=CONNECT, :protocol=websocket)
  over raw h2 frames and assert it reaches the fallback handler
- re-exec the test in a child with GODEBUG=http2xconnect=1, since the
  bundled h2 server otherwise rejects extended CONNECT before any
  handler runs and the setting is read once at startup
- bound the child with a context timeout, merge into any inherited
  GODEBUG, and require the child to report a pass so a zero-match
  -test.run cannot pass silently
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants