tailscale / tailscale/tailscale

serve: WebSocket handshakes fail when the client negotiates HTTP/2 (RFC 8441 extended CONNECT not proxied)

Open
#20,882 0 comments 0 reactions 0 assignees View on GitHub
pod/network-features services
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.