Skip to content

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

Merged
sukunrt merged 8 commits into
masterfrom
feat/ws-fallback-http-handler
Jun 3, 2026
Merged

feat(websocket): share port with HTTP handler#3509
sukunrt merged 8 commits into
masterfrom
feat/ws-fallback-http-handler

Conversation

@lidel

@lidel lidel commented May 15, 2026

Copy link
Copy Markdown
Member

Adds websocket.WithHTTPHandler(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, plus websocket.WithHTTPServerConfig(func(*http.Server)) to configure that server's timeouts and HTTP/2 settings.

👉 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 (net/http Protocols with SetUnencryptedHTTP2). 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.WithHTTPHandler(httpHandler),
    websocket.WithHTTPServerConfig(func(s *http.Server) {
        s.IdleTimeout = 30 * time.Second
        s.HTTP2 = &http.HTTP2Config{MaxConcurrentStreams: 256}
    }),
)

Important

Real-world example in ipfs/kubo#11333, where kubo uses both opt-in funcs to expose its trustless gateway on the AutoTLS-secured /ws and /tls/ws TCP ports, on one Let's Encrypt cert:

  • WithHTTPHandler installs the trustless gateway handler (h2 required over TLS, h1+h2c allowed cleartext).
  • WithHTTPServerConfig sets streaming-safe timeouts and HTTP/2 limits, deriving WriteByteTimeout/SendPingTimeout from Gateway.RetrievalTimeout so the gateway's own 504 fires before the connection is dropped.

An in-process AutoTLS canary (Pebble + p2p-forge) exercises the full path end to end.

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

lidel commented May 19, 2026

Copy link
Copy Markdown
Member Author

@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.

@lidel lidel May 29, 2026

Copy link
Copy Markdown
Member Author

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
Comment thread p2p/transport/websocket/websocket.go Outdated
Comment thread p2p/transport/websocket/websocket.go Outdated
Comment thread p2p/transport/websocket/websocket.go Outdated
Comment thread p2p/transport/websocket/listener.go Outdated
Comment on lines +146 to +171
// On a plaintext /ws listener with a fallback handler, wrap the handler
// so the same listener also accepts HTTP/2 cleartext (h2c). Reverse
// proxies that talk h2c to backends (Caddy, Traefik, nginx with the
// right config) get connection multiplexing for free; reverse proxies
// that only speak h1 to backends keep working through the same handler.
// On a /tls/ws listener we don't need this: h2 is negotiated via ALPN
// inside http.Server.ServeTLS.
if !parsed.isWSS && fallbackHTTPHandler != nil {
ln.server.Handler = h2c.NewHandler(ln, &http2.Server{})
}
// Note on RFC 8441 (WebSocket-over-HTTP/2):
//
// We do NOT explicitly enable the HTTP/2 SETTINGS_ENABLE_CONNECT_PROTOCOL
// flag here, because Go's bundled http2 stack and golang.org/x/net/http2
// do not yet expose a per-server API for it (golang/go#71128). The flag
// is gated by a process-global GODEBUG=http2xconnect=1, which a library
// must not set on its own.
//
// The dispatch in ServeHTTP is, however, written to upgrade
// transparently: it defers the "is this a WebSocket upgrade?" decision
// to ws.IsWebSocketUpgrade. When upstream Go exposes the setting and
// gorilla/websocket adds server-side ext-CONNECT support (most likely
// by extending IsWebSocketUpgrade and Upgrader.Upgrade to recognise
// `:method=CONNECT, :protocol=websocket`), this listener will accept
// WS-over-HTTP/2 with no code change — only a gorilla bump and a
// future Go upgrade.

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.

You can structure this code to be less go defaults dependent by explicitly configuring the http2 server using http2.ConfigureServer
And then we can just opt in to Extended Connect always, and don't have to depend on the godebug for tests.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks, I split this into the two things you raised:

On making it less go-defaults dependent: done in 0bf17d2. It configures the server explicitly, dropping the x/net/http2/h2c wrapper for net/http Protocols so one server handles HTTP/1.1, HTTP/2, and h2c, following the http2.ConfigureServer pattern. It also adds WithHTTPServerConfig(func(*http.Server)) so callers set timeouts and HTTP/2 options on a transport-owned server.

On opting into Extended Connect always: there is no per-server switch, and we should not advertise it anyway. In golang.org/x/net/http2 (v0.50.0 and the latest v0.55.0) a single package-global decides whether the server sends SETTINGS_ENABLE_CONNECT_PROTOCOL, flipped only by GODEBUG=http2xconnect=1 at startup; http2.ConfigureServer, http2.Server, and net/http.HTTP2Config expose no field for it (golang/go#71128 turned it off by default). Even with a switch, advertising it would break browser wss://: gorilla has no server-side RFC 8441, so dispatch (via ws.IsWebSocketUpgrade, which reads only HTTP/1.1 Upgrade headers) would route a browser's :method=CONNECT, :protocol=websocket to the fallback handler instead of upgrading. TestModernBrowserWSSFlow pins the absence of the flag; the GODEBUG re-exec in the ext-CONNECT test only simulates that future to prove dispatch stays correct.

Rationale now recorded in bb289fc.

Comment thread p2p/transport/websocket/listener.go
@sukunrt

sukunrt commented Jun 2, 2026

Copy link
Copy Markdown
Member

@lidel We can merge it after fixing the verbose comments. But I think this design would be better: #3509 (comment)

lidel added 3 commits June 2, 2026 19:39
Rename the internal fallbackHTTPHandler field to httpHandler to match the option.
Let callers tune the http.Server that serves the fallback handler (timeouts,
HTTP/2 settings), following the http2.ConfigureServer pattern: the function
configures a transport-owned server, and the transport sets Handler,
ConnContext, and TLSConfig. Apply safe defaults (ReadHeaderTimeout, IdleTimeout)
when only WithHTTPHandler is set.

Serve HTTP/2 cleartext through net/http Protocols, so one server handles
HTTP/1.1, HTTP/2, and h2c uniformly, and the per-connection context reaches
h2c streams.
The design rationale for sharing the WebSocket port:

A /tls/ws listener looks like a normal HTTPS endpoint that a single probe
cannot fingerprint as libp2p, so it is harder to censor.

The server intentionally does not advertise WebSocket-over-HTTP/2 (RFC 8441):
gorilla/websocket has no server-side support, so it omits
SETTINGS_ENABLE_CONNECT_PROTOCOL and browsers fall back to HTTP/1.1, where
the upgrade works.
@lidel
lidel force-pushed the feat/ws-fallback-http-handler branch from 8a828bd to bb289fc Compare June 2, 2026 17:41
@lidel

lidel commented Jun 2, 2026

Copy link
Copy Markdown
Member Author

Thanks @sukunrt, I went through feedback and did my best:

  • I trimmed the long WithHTTPHandler doc comment down to what it does and how requests are routed. The "why" (censorship resistance and the RFC 8441 details) now lives in the commit message instead (bb289fc).
  • I renamed the option from WithFallbackHTTPHandler to WithHTTPHandler, and the internal field to match (9762ed1).
  • I added WithHTTPServerConfig(func(*http.Server)) so you can set your own timeouts and HTTP/2 options. The plaintext path now uses net/http Protocols instead of the x/net/http2/h2c wrapper, so a single server handles HTTP/1.1, HTTP/2, and h2c, following the http2.ConfigureServer pattern. When only WithHTTPHandler is set, it applies safe default timeouts, which also keeps fallback connections from holding on to the resource-manager budget you pointed out (0bf17d2).

One thing I could not do the way you suggested: turning Extended Connect on by default. There is no per-server switch for it today, and turning it on would break browser wss://, because gorilla cannot speak WebSocket-over-HTTP/2 on the server side yet. I wrote up the full reasoning here: #3509 (comment)

Happy to change anything if you see a better way. Thanks again for the careful look.

@sukunrt

sukunrt commented Jun 3, 2026

Copy link
Copy Markdown
Member

One thing I could not do the way you suggested: turning Extended Connect on by default. There is no per-server switch for it today, and turning it on would break browser wss://, because gorilla cannot speak WebSocket-over-HTTP/2 on the server side yet. I wrote up the full reasoning here: #3509 (comment)

Thanks @lidel . I just assumed there would be such an option.

@sukunrt
sukunrt merged commit e6309a2 into master Jun 3, 2026
11 checks passed
lidel added a commit to ipfs/kubo that referenced this pull request Jun 3, 2026
Track the released go-libp2p instead of the pre-merge PR commit now
that libp2p/go-libp2p#3509 has landed on master. The HTTPProvider
fallback handler comment also drops the old WithFallbackHTTPHandler
name in favor of the merged WithHTTPHandler API.
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