tailscale / tailscale/tailscale
serve: WebSocket handshakes fail when the client negotiates HTTP/2 (RFC 8441 extended CONNECT not proxied)
- Dominant language
- Go
- Stars
- 36.5k
- Forks
- 3.2k
- Avg merge
- 2d 3h
- Merged PRs (30d)
- 123
Description
### What is the issue?
When a service is exposed with `tailscale serve --https=` and the client negotiates HTTP/2 over ALPN (the default for browsers and curl), WebSocket handshakes never reach the backend as upgrades. The backend receives a plain `GET` instead and replies `426 Upgrade Required`, so the client's WebSocket cannot open.
The same handshake succeeds when the client is forced to HTTP/1.1, and it also works with `tailscale serve --tcp=` (raw passthrough).
Real-world impact: browser UIs that rely on a WebSocket downlink freeze — HTTP sends keep working, but no live updates arrive.
### Steps to reproduce
1. Have a backend that requires a WebSocket upgrade (any `ws` server; the backend I used answers `426` to a plain GET on the WS path, which makes the lost upgrade visible).
2.
2. `tailscale serve --bg --https=3443 http://127.0.0.1:3080`
3.
3. From a tailnet peer, run both curls:
4.
```sh
# HTTP/2 via ALPN (default) — FAILS: backend sees a plain GET, answers 426
curl -v --resolve HOST.ts.net:3443:100.x.y.z \
-H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' \
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
https://HOST.ts.net:3443/api/events.mux
# → HTTP/2 426
# Forced HTTP/1.1 — WORKS: backend answers 101
curl -v --http1.1 --resolve HOST.ts.net:3443:100.x.y.z \
-H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' \
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
https://HOST.ts.net:3443/api/events.mux
# → HTTP/1.1 101 Switching Protocols
```
### Expected behavior
WebSocket handshakes should work regardless of the negotiated HTTP version, as they do on nginx/Caddy/Envoy, which bridge RFC 8441 extended CONNECT to an HTTP/1.1 upgrade on the backend leg.
### Why it happens (reading serve.go)
`serve`'s HTTPS front terminates TLS with HTTP/2 advertised via ALPN and proxies through `httputil.ReverseProxy` (`ipn/ipnlocal/serve.go`). On HTTP/1.1, RFC 6455 `Upgrade` headers pass through and work. On HTTP/2, browsers/curl use RFC 8441 extended CONNECT (`:protocol=websocket`); Go's `x/net/http2` server does not implement server-side extended CONNECT ([golang/go#72071]), so the request degrades to a plain GET before it reaches the proxy/backend.
[golang/go#72071]: https://github.com/golang/go/issues/72071
### Suggested fixes (either would resolve this)
1. Add a way to advertise only `http/1.1` for `serve --https` TLS (e.g. a `--h1-only` flag), so WebSocket upgrades follow the working RFC 6455 path. h2 is rarely needed for a tailnet-only reverse proxy.
2.
2. Implement h2 extended CONNECT → h1 upgrade bridging in the serve proxy.
3.
Related: #18651 (query parameters stripped from WS upgrade requests), #18827 (serve WS connections drop every 10-40 s).
### Environment
- Tailscale 1.102.2 (macOS, Homebrew CLI daemon, `require_root` service)
-
- Clients tested: curl 8.x (h2 and forced h1), Safari
-
Contributor guide
Research direction
Start in ipn/ipnlocal/serve.go and trace the --https reverse-proxy path, then reproduce the difference with the provided curl HTTP/2 and HTTP/1.1 commands. Determine how the negotiated HTTP/2 WebSocket handshake reaches the proxy; done means a WebSocket backend receives the upgrade successfully over the default HTTPS path without breaking the working HTTP/1.1 behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- backend, networking
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100