From d71edf34b3dfbcffe3dec08c0b3b459537ddec24 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 29 Sep 2025 20:48:26 +0300 Subject: [PATCH 01/19] feat: add experimental http2 support Closes #4 --- README.md | 9 +- accept.go | 254 ++++++++++++----- accept_test.go | 2 +- autobahn_test.go | 2 +- ci/test.sh | 16 +- dial.go | 123 +++++++- doc.go | 26 +- http2.go | 59 ++++ internal/examples/go.mod | 5 +- internal/examples/go.sum | 8 +- internal/examples/http2/README.md | 62 ++++ internal/examples/http2/client.go | 123 ++++++++ internal/examples/http2/main.go | 67 +++++ internal/examples/http2/server.go | 172 ++++++++++++ internal/thirdparty/go.mod | 37 ++- internal/thirdparty/go.sum | 93 +++--- internal/thirdparty/http2/http2_test.go | 358 ++++++++++++++++++++++++ protocol.go | 55 ++++ ws_js.go | 8 + 19 files changed, 1325 insertions(+), 154 deletions(-) create mode 100644 http2.go create mode 100644 internal/examples/http2/README.md create mode 100644 internal/examples/http2/client.go create mode 100644 internal/examples/http2/main.go create mode 100644 internal/examples/http2/server.go create mode 100644 internal/thirdparty/http2/http2_test.go create mode 100644 protocol.go diff --git a/README.md b/README.md index 3f174d5b..c6b76503 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,11 @@ go get github.com/coder/websocket - [RFC 7692](https://tools.ietf.org/html/rfc7692) permessage-deflate compression - [CloseRead](https://pkg.go.dev/github.com/coder/websocket#Conn.CloseRead) helper for write only connections - Compile to [Wasm](https://pkg.go.dev/github.com/coder/websocket#hdr-Wasm) +- Experimental support for HTTP/2 extended CONNECT (RFC 8441) + - Requires opt-in via the [AcceptOptions](https://pkg.go.dev/github.com/coder/websocket#AcceptOptions) and [DialOptions](https://pkg.go.dev/github.com/coder/websocket#DialOptions) Protocol option + - Clients must provide a `http2.Transport` + - Servers must be started with `GODEBUG=http2xconnect=1`, see https://github.com/golang/go/issues/53208 + - See the [http2 example](./internal/examples/http2) ## Roadmap @@ -43,7 +48,7 @@ See GitHub issues for minor issues but the major future enhancements are: - [ ] Graceful shutdown helpers [#209](https://github.com/coder/websocket/issues/209) - [ ] Assembly for WebSocket masking [#16](https://github.com/coder/websocket/issues/16) - WIP at [#326](https://github.com/coder/websocket/pull/326), about 3x faster -- [ ] HTTP/2 [#4](https://github.com/coder/websocket/issues/4) +- [x] HTTP/2 extended CONNECT (RFC 8441) [#4](https://github.com/coder/websocket/issues/4) - [ ] The holy grail [#402](https://github.com/coder/websocket/issues/402) ## Examples @@ -53,6 +58,8 @@ For a production quality example that demonstrates the complete API, see the For a full stack example, see the [chat example](./internal/examples/chat). +For a HTTP/2 WebSocket example, see the [http2 example](./internal/examples/http2). + ### Server ```go diff --git a/accept.go b/accept.go index 25c1be7d..9debc666 100644 --- a/accept.go +++ b/accept.go @@ -27,7 +27,14 @@ type AcceptOptions struct { // reject it, close the connection when c.Subprotocol() == "". Subprotocols []string - // InsecureSkipVerify is used to disable Accept's origin verification behaviour. + // Protocol selects which HTTP version to accept. Zero value defaults to + // ProtocolHTTP1. ProtocolAcceptAny allows accepting either HTTP/1.1 or + // HTTP/2. + // + // Experimental: This feature is experimental and may change in the future. + Protocol Protocol + + // InsecureSkipVerify is used to disable Accept's origin verification behavior. // // You probably want to use OriginPatterns instead. InsecureSkipVerify bool @@ -81,12 +88,20 @@ type AcceptOptions struct { OnPongReceived func(ctx context.Context, payload []byte) } -func (opts *AcceptOptions) cloneWithDefaults() *AcceptOptions { +func (opts *AcceptOptions) cloneWithDefaults() (*AcceptOptions, error) { var o AcceptOptions if opts != nil { o = *opts } - return &o + + // Defaults to HTTP/1.1 only to preserve existing behavior (zero value). + switch o.Protocol { + case ProtocolAcceptAny, ProtocolHTTP1, ProtocolHTTP2: + default: + return nil, fmt.Errorf("websocket: invalid protocol for accept options: %s", o.Protocol) + } + + return &o, nil } // Accept accepts a WebSocket handshake from a client and upgrades the @@ -106,13 +121,20 @@ func Accept(w http.ResponseWriter, r *http.Request, opts *AcceptOptions) (*Conn, func accept(w http.ResponseWriter, r *http.Request, opts *AcceptOptions) (_ *Conn, err error) { defer errd.Wrap(&err, "failed to accept WebSocket connection") - errCode, err := verifyClientRequest(w, r) + opts, err = opts.cloneWithDefaults() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return nil, err + } + + // Verify client request and determine handshake proto (h1 or h2). + proto, key, errCode, err := verifyClientRequest(w, r, opts) if err != nil { http.Error(w, err.Error(), errCode) return nil, err } - opts = opts.cloneWithDefaults() + // Origin/auth checks (common for both H1 and H2). if !opts.InsecureSkipVerify { err = authenticateOrigin(r, opts.OriginPatterns) if err != nil { @@ -125,104 +147,206 @@ func accept(w http.ResponseWriter, r *http.Request, opts *AcceptOptions) (_ *Con } } - hj, ok := hijacker(w) - if !ok { - err = errors.New("http.ResponseWriter does not implement http.Hijacker") - http.Error(w, http.StatusText(http.StatusNotImplemented), http.StatusNotImplemented) - return nil, err - } + switch proto { + case ProtocolHTTP2: + // Prepare response headers for H2 (no Connection/Upgrade). + w.Header().Set("Sec-WebSocket-Accept", secWebSocketAccept(key)) + + subproto := selectSubprotocol(r, opts.Subprotocols) + if subproto != "" { + w.Header().Set("Sec-WebSocket-Protocol", subproto) + } + + copts, ok := selectDeflate(websocketExtensions(r.Header), opts.CompressionMode) + if ok { + w.Header().Set("Sec-WebSocket-Extensions", copts.String()) + } - w.Header().Set("Upgrade", "websocket") - w.Header().Set("Connection", "Upgrade") + // RFC 8441 requires a 2xx response for extended CONNECT. + w.WriteHeader(http.StatusOK) + // Flush the response immediately to complete the extended CONNECT + // handshake before we start streaming on the tunnel. + rc := http.NewResponseController(w) + if err := rc.Flush(); err != nil { + return nil, err + } + + stream := &h2ServerStream{ReadCloser: r.Body, Writer: w, flush: rc.Flush} + return newConn(connConfig{ + subprotocol: w.Header().Get("Sec-WebSocket-Protocol"), + rwc: stream, + client: false, + copts: copts, + flateThreshold: opts.CompressionThreshold, + onPingReceived: opts.OnPingReceived, + onPongReceived: opts.OnPongReceived, + br: getBufioReader(stream), + bw: getBufioWriter(stream), + }), nil + + case ProtocolHTTP1: + hj, ok := hijacker(w) + if !ok { + err = errors.New("http.ResponseWriter does not implement http.Hijacker") + http.Error(w, http.StatusText(http.StatusNotImplemented), http.StatusNotImplemented) + return nil, err + } + + w.Header().Set("Upgrade", "websocket") + w.Header().Set("Connection", "Upgrade") + w.Header().Set("Sec-WebSocket-Accept", secWebSocketAccept(key)) - key := r.Header.Get("Sec-WebSocket-Key") - w.Header().Set("Sec-WebSocket-Accept", secWebSocketAccept(key)) + subproto := selectSubprotocol(r, opts.Subprotocols) + if subproto != "" { + w.Header().Set("Sec-WebSocket-Protocol", subproto) + } + + copts, ok := selectDeflate(websocketExtensions(r.Header), opts.CompressionMode) + if ok { + w.Header().Set("Sec-WebSocket-Extensions", copts.String()) + } + + w.WriteHeader(http.StatusSwitchingProtocols) + // See https://github.com/coder/websocket/issues/166. + if ginWriter, ok := w.(interface { + WriteHeaderNow() + }); ok { + ginWriter.WriteHeaderNow() + } + + netConn, brw, err := hj.Hijack() + if err != nil { + err = fmt.Errorf("failed to hijack connection: %w", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return nil, err + } - subproto := selectSubprotocol(r, opts.Subprotocols) - if subproto != "" { - w.Header().Set("Sec-WebSocket-Protocol", subproto) + // https://github.com/golang/go/issues/32314 + b, _ := brw.Reader.Peek(brw.Reader.Buffered()) + brw.Reader.Reset(io.MultiReader(bytes.NewReader(b), netConn)) + + return newConn(connConfig{ + subprotocol: w.Header().Get("Sec-WebSocket-Protocol"), + rwc: netConn, + client: false, + copts: copts, + flateThreshold: opts.CompressionThreshold, + onPingReceived: opts.OnPingReceived, + onPongReceived: opts.OnPongReceived, + + br: brw.Reader, + bw: brw.Writer, + }), nil + default: + http.Error(w, "unsupported protocol: "+r.Proto, http.StatusBadRequest) + return nil, errors.New("unsupported protocol: " + r.Proto) } +} + +func verifyClientRequest(w http.ResponseWriter, r *http.Request, opts *AcceptOptions) (proto Protocol, key string, errCode int, err error) { + if r.ProtoMajor == 2 { + switch opts.Protocol { + case ProtocolHTTP1: + return ProtocolHTTP2, "", http.StatusBadRequest, errors.New("HTTP/2 extended CONNECT refused: server only accepts HTTP/1.1 Upgrade") + } - copts, ok := selectDeflate(websocketExtensions(r.Header), opts.CompressionMode) - if ok { - w.Header().Set("Sec-WebSocket-Extensions", copts.String()) + // HTTP/2 extended CONNECT (RFC 8441) path. + key, errCode, err = verifyClientRequestH2(w, r) + if err != nil { + return ProtocolHTTP2, "", errCode, err + } + return ProtocolHTTP2, key, 0, nil } - w.WriteHeader(http.StatusSwitchingProtocols) - // See https://github.com/coder/websocket/issues/166 - if ginWriter, ok := w.(interface { - WriteHeaderNow() - }); ok { - ginWriter.WriteHeaderNow() + switch opts.Protocol { + case ProtocolHTTP2: + return ProtocolHTTP1, "", http.StatusBadRequest, errors.New("HTTP/1.1 Upgrade refused: server requires HTTP/2 extended CONNECT") } - netConn, brw, err := hj.Hijack() + // HTTP/1.1 GET/Upgrade handshake validation. + key, errCode, err = verifyClientRequestH1(w, r) if err != nil { - err = fmt.Errorf("failed to hijack connection: %w", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return nil, err + return ProtocolHTTP1, "", errCode, err } - - // https://github.com/golang/go/issues/32314 - b, _ := brw.Reader.Peek(brw.Reader.Buffered()) - brw.Reader.Reset(io.MultiReader(bytes.NewReader(b), netConn)) - - return newConn(connConfig{ - subprotocol: w.Header().Get("Sec-WebSocket-Protocol"), - rwc: netConn, - client: false, - copts: copts, - flateThreshold: opts.CompressionThreshold, - onPingReceived: opts.OnPingReceived, - onPongReceived: opts.OnPongReceived, - - br: brw.Reader, - bw: brw.Writer, - }), nil + return ProtocolHTTP1, key, 0, nil } -func verifyClientRequest(w http.ResponseWriter, r *http.Request) (errCode int, _ error) { - if !r.ProtoAtLeast(1, 1) { - return http.StatusUpgradeRequired, fmt.Errorf("WebSocket protocol violation: handshake request must be at least HTTP/1.1: %q", r.Proto) +// verifyClientRequestH1 validates an HTTP/1.1 WebSocket GET/Upgrade request. +func verifyClientRequestH1(w http.ResponseWriter, r *http.Request) (key string, errCode int, _ error) { + if !r.ProtoAtLeast(1, 1) || r.ProtoMajor != 1 { + return "", http.StatusUpgradeRequired, fmt.Errorf("WebSocket protocol violation: handshake request must be at least HTTP/1.1: %q", r.Proto) } if !headerContainsTokenIgnoreCase(r.Header, "Connection", "Upgrade") { w.Header().Set("Connection", "Upgrade") w.Header().Set("Upgrade", "websocket") - return http.StatusUpgradeRequired, fmt.Errorf("WebSocket protocol violation: Connection header %q does not contain Upgrade", r.Header.Get("Connection")) + return "", http.StatusUpgradeRequired, fmt.Errorf("WebSocket protocol violation: Connection header %q does not contain Upgrade", r.Header.Get("Connection")) } if !headerContainsTokenIgnoreCase(r.Header, "Upgrade", "websocket") { w.Header().Set("Connection", "Upgrade") w.Header().Set("Upgrade", "websocket") - return http.StatusUpgradeRequired, fmt.Errorf("WebSocket protocol violation: Upgrade header %q does not contain websocket", r.Header.Get("Upgrade")) + return "", http.StatusUpgradeRequired, fmt.Errorf("WebSocket protocol violation: Upgrade header %q does not contain websocket", r.Header.Get("Upgrade")) + } + + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + return "", http.StatusMethodNotAllowed, fmt.Errorf("WebSocket protocol violation: handshake request method is not GET but %q", r.Method) + } + + key, errCode, err := validateSecWebSocketHeaders(w, r) + if err != nil { + return "", errCode, err + } + + return key, 0, nil +} + +func verifyClientRequestH2(w http.ResponseWriter, r *http.Request) (key string, errCode int, _ error) { + if r.ProtoMajor != 2 { + return "", http.StatusBadRequest, fmt.Errorf("WebSocket protocol violation: handshake request must be HTTP/2: %q", r.Proto) + } + + if r.Header.Get(":protocol") != "websocket" { + return "", http.StatusBadRequest, fmt.Errorf("WebSocket protocol violation: :protocol header not set or does not match websocket: %q", r.Header.Get(":protocol")) } - if r.Method != "GET" { - return http.StatusMethodNotAllowed, fmt.Errorf("WebSocket protocol violation: handshake request method is not GET but %q", r.Method) + if r.Method != http.MethodConnect { + w.Header().Set("Allow", http.MethodConnect) + return "", http.StatusMethodNotAllowed, fmt.Errorf("WebSocket protocol violation: handshake request method is not CONNECT but %q", r.Method) } + key, errCode, err := validateSecWebSocketHeaders(w, r) + if err != nil { + return "", errCode, err + } + + return key, 0, nil +} + +// validateSecWebSocketHeaders validates Sec-WebSocket-Version/Sec-WebSocket-Key +// and returns the trimmed key. +// +// It sets Sec-WebSocket-Version: 13 on version mismatch. +func validateSecWebSocketHeaders(w http.ResponseWriter, r *http.Request) (key string, errCode int, _ error) { if r.Header.Get("Sec-WebSocket-Version") != "13" { w.Header().Set("Sec-WebSocket-Version", "13") - return http.StatusBadRequest, fmt.Errorf("unsupported WebSocket protocol version (only 13 is supported): %q", r.Header.Get("Sec-WebSocket-Version")) + return "", http.StatusBadRequest, fmt.Errorf("unsupported WebSocket protocol version (only 13 is supported): %q", r.Header.Get("Sec-WebSocket-Version")) } websocketSecKeys := r.Header.Values("Sec-WebSocket-Key") if len(websocketSecKeys) == 0 { - return http.StatusBadRequest, errors.New("WebSocket protocol violation: missing Sec-WebSocket-Key") + return "", http.StatusBadRequest, errors.New("WebSocket protocol violation: missing Sec-WebSocket-Key") } - if len(websocketSecKeys) > 1 { - return http.StatusBadRequest, errors.New("WebSocket protocol violation: multiple Sec-WebSocket-Key headers") + return "", http.StatusBadRequest, errors.New("WebSocket protocol violation: multiple Sec-WebSocket-Key headers") } - - // The RFC states to remove any leading or trailing whitespace. - websocketSecKey := strings.TrimSpace(websocketSecKeys[0]) - if v, err := base64.StdEncoding.DecodeString(websocketSecKey); err != nil || len(v) != 16 { - return http.StatusBadRequest, fmt.Errorf("WebSocket protocol violation: invalid Sec-WebSocket-Key %q, must be a 16 byte base64 encoded string", websocketSecKey) + key = strings.TrimSpace(websocketSecKeys[0]) + if v, err := base64.StdEncoding.DecodeString(key); err != nil || len(v) != 16 { + return "", http.StatusBadRequest, fmt.Errorf("WebSocket protocol violation: invalid Sec-WebSocket-Key %q, must be a 16 byte base64 encoded string", key) } - return 0, nil + return key, 0, nil } func authenticateOrigin(r *http.Request, originHosts []string) error { diff --git a/accept_test.go b/accept_test.go index 92dbfcc7..0718a347 100644 --- a/accept_test.go +++ b/accept_test.go @@ -342,7 +342,7 @@ func Test_verifyClientHandshake(t *testing.T) { r.Header.Add(k, v) } - _, err := verifyClientRequest(httptest.NewRecorder(), r) + _, _, _, err := verifyClientRequest(httptest.NewRecorder(), r, &AcceptOptions{Protocol: ProtocolHTTP1}) if tc.success { assert.Success(t, err) } else { diff --git a/autobahn_test.go b/autobahn_test.go index 20b89609..a8a83706 100644 --- a/autobahn_test.go +++ b/autobahn_test.go @@ -251,7 +251,7 @@ func checkWSTestIndex(t *testing.T, path string) { switch result.BehaviorClose { case "OK", "INFORMATIONAL": default: - t.Errorf("bad close behaviour") + t.Errorf("bad close behavior") } switch result.Behavior { diff --git a/ci/test.sh b/ci/test.sh index c1c09e37..ce841703 100755 --- a/ci/test.sh +++ b/ci/test.sh @@ -12,6 +12,17 @@ mkdir -p "$coverdir" coverpkgs=$(go list ./... | grep -v websocket/internal/test | paste -sd, -) +# HTTP/2 tests are kept in a separate module currently. +echo "++++ Building HTTP/2 tests" +pushd internal/thirdparty +go test -c -cover -covermode=atomic -coverpkg=github.com/coder/websocket -o ../../ci/out/http2.test ./http2 +popd + +echo "++++ Running HTTP/2 tests: http2xconnect=0" +GODEBUG=http2xconnect=0 ./ci/out/http2.test -test.timeout=30s -test.gocoverdir="$coverdir" -test.run='.*XCONNECT_Disabled$' -test.count=1 +echo "++++ Running HTTP/2 tests: http2xconnect=1" +GODEBUG=http2xconnect=1 ./ci/out/http2.test -test.timeout=30s -test.gocoverdir="$coverdir" -test.run='.*XCONNECT_Enabled$' -test.count=1 + echo "++++ Running main tests" # -test.gocoverdir is a test binary flag, so it goes after -args. Each test # binary runs in its package directory, so the path must be absolute. @@ -24,7 +35,10 @@ popd echo "+++ Testing thirdparty" pushd internal/thirdparty -go test "$@" ./... +# The HTTP/2 tests ran above, each with its GODEBUG setting. +thirdpartypkgs=$(go list ./... | grep -v /http2) +# shellcheck disable=SC2086 # Package paths contain no spaces. +go test "$@" $thirdpartypkgs popd if [[ $# -eq 0 ]]; then diff --git a/dial.go b/dial.go index f5e4544b..1f304306 100644 --- a/dial.go +++ b/dial.go @@ -29,6 +29,12 @@ type DialOptions struct { // HTTPHeader specifies the HTTP headers included in the handshake request. HTTPHeader http.Header + // Protocol selects the HTTP version for the handshake. Zero value defaults + // to ProtocolHTTP1. ProtocolAcceptAny is not supported by Dial. + // + // Experimental: This feature is experimental and may change in the future. + Protocol Protocol + // Host optionally overrides the Host HTTP header to send. If empty, the value // of URL.Host will be used. Host string @@ -65,13 +71,21 @@ type DialOptions struct { OnPongReceived func(ctx context.Context, payload []byte) } -func (opts *DialOptions) cloneWithDefaults(ctx context.Context) (context.Context, context.CancelFunc, *DialOptions) { +func (opts *DialOptions) cloneWithDefaults(ctx context.Context) (context.Context, context.CancelFunc, *DialOptions, error) { var cancel context.CancelFunc var o DialOptions if opts != nil { o = *opts } + + // Defaults to HTTP/1.1 only to preserve existing behavior (zero value). + switch o.Protocol { + case ProtocolHTTP1, ProtocolHTTP2: + default: + return nil, nil, nil, fmt.Errorf("websocket: invalid protocol for dial options: %s", o.Protocol) + } + if o.HTTPClient == nil { o.HTTPClient = http.DefaultClient } @@ -101,7 +115,7 @@ func (opts *DialOptions) cloneWithDefaults(ctx context.Context) (context.Context } o.HTTPClient = &newClient - return ctx, cancel, &o + return ctx, cancel, &o, nil } // Dial performs a WebSocket handshake on url. @@ -125,7 +139,10 @@ func dial(ctx context.Context, urls string, opts *DialOptions, rand io.Reader) ( defer errd.Wrap(&err, "failed to WebSocket dial") var cancel context.CancelFunc - ctx, cancel, opts = opts.cloneWithDefaults(ctx) + ctx, cancel, opts, err = opts.cloneWithDefaults(ctx) + if err != nil { + return nil, nil, err + } if cancel != nil { defer cancel() } @@ -201,7 +218,20 @@ func handshakeRequest(ctx context.Context, urls string, opts *DialOptions, copts return nil, fmt.Errorf("unexpected url scheme: %q", u.Scheme) } - req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) + switch opts.Protocol { + case ProtocolHTTP2: + return handshakeRequestH2(ctx, u, opts, copts, secWebSocketKey) + case ProtocolHTTP1: + return handshakeRequestH1(ctx, u, opts, copts, secWebSocketKey) + default: + return nil, fmt.Errorf("unknown protocol: %s", opts.Protocol) + } +} + +// handshakeRequestH1 constructs the HTTP/1.1 WebSocket GET+Upgrade request. +// Behavior and headers are identical to the previous inline construction in handshakeRequest. +func handshakeRequestH1(ctx context.Context, u *url.URL, opts *DialOptions, copts *compressionOptions, secWebSocketKey string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, fmt.Errorf("failed to create new http request: %w", err) } @@ -209,6 +239,10 @@ func handshakeRequest(ctx context.Context, urls string, opts *DialOptions, copts req.Host = opts.Host } req.Header = opts.HTTPHeader.Clone() + + // Do not send H2-only headers on H1. + req.Header.Del(":protocol") + req.Header.Set("Connection", "Upgrade") req.Header.Set("Upgrade", "websocket") req.Header.Set("Sec-WebSocket-Version", "13") @@ -227,6 +261,49 @@ func handshakeRequest(ctx context.Context, urls string, opts *DialOptions, copts return resp, nil } +func handshakeRequestH2(ctx context.Context, u *url.URL, opts *DialOptions, copts *compressionOptions, secWebSocketKey string) (_ *http.Response, err error) { + // Pipe to allow immediate writes after CONNECT completes. + pr, pw := io.Pipe() + defer func() { + if err != nil { + pr.CloseWithError(err) + } + }() + + req, err := http.NewRequestWithContext(ctx, http.MethodConnect, u.String(), pr) + if err != nil { + return nil, fmt.Errorf("failed to create http2 connect handshake request: %w", err) + } + if len(opts.Host) > 0 { + req.Host = opts.Host + } + req.Header = opts.HTTPHeader.Clone() + + // Do not send H1-only headers on H2. + req.Header.Del("Connection") + req.Header.Del("Upgrade") + + // RFC 8441 protocol header. + req.Header.Set(":protocol", "websocket") + req.Header.Set("Sec-WebSocket-Version", "13") + req.Header.Set("Sec-WebSocket-Key", secWebSocketKey) + if len(opts.Subprotocols) > 0 { + req.Header.Set("Sec-WebSocket-Protocol", strings.Join(opts.Subprotocols, ",")) + } + if copts != nil { + req.Header.Set("Sec-WebSocket-Extensions", copts.String()) + } + + resp, err := opts.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send http2 connect handshake request: %w", err) + } + + resp.Body = &h2ClientStream{ReadCloser: resp.Body, WriteCloser: pw} + + return resp, nil +} + func secWebSocketKey(rr io.Reader) (string, error) { if rr == nil { rr = rand.Reader @@ -240,6 +317,17 @@ func secWebSocketKey(rr io.Reader) (string, error) { } func verifyServerResponse(opts *DialOptions, copts *compressionOptions, secWebSocketKey string, resp *http.Response) (*compressionOptions, error) { + switch opts.Protocol { + case ProtocolHTTP2: + return verifyServerResponseH2(opts, copts, secWebSocketKey, resp) + case ProtocolHTTP1: + return verifyServerResponseH1(opts, copts, secWebSocketKey, resp) + default: + return nil, fmt.Errorf("unknown protocol: %s", opts.Protocol) + } +} + +func verifyServerResponseH1(opts *DialOptions, copts *compressionOptions, secWebSocketKey string, resp *http.Response) (*compressionOptions, error) { if resp.StatusCode != http.StatusSwitchingProtocols { return nil, fmt.Errorf("expected handshake response status code %v but got %v", http.StatusSwitchingProtocols, resp.StatusCode) } @@ -267,6 +355,31 @@ func verifyServerResponse(opts *DialOptions, copts *compressionOptions, secWebSo return verifyServerExtensions(copts, resp.Header) } +func verifyServerResponseH2(opts *DialOptions, copts *compressionOptions, secWebSocketKey string, resp *http.Response) (*compressionOptions, error) { + if resp.ProtoMajor != 2 { + return nil, fmt.Errorf("expected HTTP/2 response but got: %s", resp.Proto) + } + + // Expect 2xx for extended CONNECT (RFC 8441). + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("expected 2xx status code for extended CONNECT but got: %d", resp.StatusCode) + } + + if resp.Header.Get("Sec-WebSocket-Accept") != secWebSocketAccept(secWebSocketKey) { + return nil, fmt.Errorf("WebSocket protocol violation: invalid Sec-WebSocket-Accept %q, key %q", + resp.Header.Get("Sec-WebSocket-Accept"), + secWebSocketKey, + ) + } + + err := verifySubprotocol(opts.Subprotocols, resp) + if err != nil { + return nil, err + } + + return verifyServerExtensions(copts, resp.Header) +} + func verifySubprotocol(subprotos []string, resp *http.Response) error { proto := resp.Header.Get("Sec-WebSocket-Protocol") if proto == "" { @@ -290,7 +403,7 @@ func verifyServerExtensions(copts *compressionOptions, h http.Header) (*compress ext := exts[0] if ext.name != "permessage-deflate" || len(exts) > 1 || copts == nil { - return nil, fmt.Errorf("WebSocket protcol violation: unsupported extensions from server: %+v", exts[1:]) + return nil, fmt.Errorf("WebSocket protocol violation: unsupported extensions from server: %+v", exts) } _copts := *copts diff --git a/doc.go b/doc.go index 0c7f8316..c3c07251 100644 --- a/doc.go +++ b/doc.go @@ -4,6 +4,8 @@ // // https://tools.ietf.org/html/rfc6455 // +// # Overview +// // Use Dial to dial a WebSocket server. // // Use Accept to accept a WebSocket client. @@ -12,13 +14,26 @@ // // The examples are the best way to understand how to correctly use the library. // -// The wsjson subpackage contain helpers for JSON and protobuf messages. +// The wsjson subpackage contains helpers for JSON and protobuf messages. // // More documentation at https://github.com/coder/websocket. // -// # Wasm +// # HTTP/2 +// +// The package supports WebSocket over HTTP/2 via the extended CONNECT +// protocol (RFC 8441). +// +// This functionality is currently opt-in and requires setting the Protocol +// option on the AcceptOptions or DialOptions. When not set, HTTP/1.1 is used. +// +// Server-side extended CONNECT functionality must currently be enabled by +// setting GODEBUG=http2xconnect=1, see https://github.com/golang/go/issues/53208. +// +// See internal/examples/http2 for a minimal example. // -// The client side supports compiling to Wasm. +// # WebAssembly (Wasm) +// +// The client side supports compiling to WebAssembly (Wasm). // It wraps the WebSocket browser API. // // See https://developer.mozilla.org/en-US/docs/Web/API/WebSocket @@ -26,8 +41,9 @@ // Some important caveats to be aware of: // // - Accept always errors out -// - Conn.Ping is no-op -// - Conn.CloseNow is Close(StatusGoingAway, "") +// - Protocol in DialOptions and AcceptOptions is no-op // - HTTPClient, HTTPHeader and CompressionMode in DialOptions are no-op // - *http.Response from Dial is &http.Response{} with a 101 status code on success +// - Conn.Ping is no-op +// - Conn.CloseNow is Close(StatusGoingAway, "") package websocket // import "github.com/coder/websocket" diff --git a/http2.go b/http2.go new file mode 100644 index 00000000..fa5d4046 --- /dev/null +++ b/http2.go @@ -0,0 +1,59 @@ +package websocket + +import ( + "errors" + "fmt" + "io" +) + +var ( + _ io.ReadWriteCloser = (*h2ServerStream)(nil) + _ io.ReadWriteCloser = (*h2ClientStream)(nil) +) + +// h2ServerStream is a minimal io.ReadWriteCloser for an HTTP/2 extended CONNECT +// tunnel. Read reads from the request body and Write writes to the response +// writer. To ensure data transmission, the stream is flushed on write. +type h2ServerStream struct { + io.ReadCloser // http.Request.Body + io.Writer // http.ResponseWriter + flush func() error // http.ResponseWriter +} + +func (s *h2ServerStream) Read(p []byte) (int, error) { + return s.ReadCloser.Read(p) +} + +func (s *h2ServerStream) Write(p []byte) (int, error) { + n, err := s.Writer.Write(p) + if err != nil { + return n, err + } + err = s.Flush() + return n, err +} + +func (s *h2ServerStream) Flush() error { + if err := s.flush(); err != nil { + return fmt.Errorf("h2ServerStream: failed to flush: %w", err) + } + return nil +} + +func (s *h2ServerStream) Close() error { + // TODO(mafredri): Verify if the flush is necessary before closing the reader. + err := s.Flush() + return errors.Join(err, s.ReadCloser.Close()) +} + +// h2ClientStream is a minimal io.ReadWriteCloser for an HTTP/2 extended CONNECT +// tunnel. Read reads from the response body and Write writes to the PipeWriter +// that feeds the request body. +type h2ClientStream struct { + io.ReadCloser // http.Response.Body + io.WriteCloser // http.Request.Body +} + +func (s *h2ClientStream) Close() error { + return errors.Join(s.ReadCloser.Close(), s.WriteCloser.Close()) +} diff --git a/internal/examples/go.mod b/internal/examples/go.mod index ca2f56cc..0da3d2ea 100644 --- a/internal/examples/go.mod +++ b/internal/examples/go.mod @@ -6,5 +6,8 @@ replace github.com/coder/websocket => ../.. require ( github.com/coder/websocket v0.0.0-00010101000000-000000000000 - golang.org/x/time v0.7.0 + golang.org/x/net v0.44.0 + golang.org/x/time v0.13.0 ) + +require golang.org/x/text v0.29.0 // indirect diff --git a/internal/examples/go.sum b/internal/examples/go.sum index 60aa8f9a..c8351e3b 100644 --- a/internal/examples/go.sum +++ b/internal/examples/go.sum @@ -1,2 +1,6 @@ -golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= -golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI= +golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= diff --git a/internal/examples/http2/README.md b/internal/examples/http2/README.md new file mode 100644 index 00000000..c80951ab --- /dev/null +++ b/internal/examples/http2/README.md @@ -0,0 +1,62 @@ +# HTTP/2 WebSocket Example + +This example shows a minimal WebSocket echo over HTTP/2 using extended CONNECT +(RFC 8441) with `github.com/coder/websocket`. + +It supports: + +- h2c (cleartext HTTP/2) via `ws://` +- TLS + HTTP/2 via `wss://` (self-signed by default, or bring your own cert/key) + +## Run + +Cleartext HTTP/2: + +```console +# Server. +$ cd examples/http2 +$ GODEBUG=http2xconnect=1 go run . server -addr :8080 +listening on ws://127.0.0.1:8080 (h2c) + +# Client. +$ go run . client ws://127.0.0.1:8080 +Hello over HTTP/2 WebSocket! +``` + +TLS (wss) with self-signed cert: + +```console +# Server. +$ cd examples/http2 +$ GODEBUG=http2xconnect=1 go run . server -tls -addr :8443 +listening on wss://127.0.0.1:8443 (self-signed) + +# Client. +$ go run . client -insecure wss://localhost:8443 +Hello over HTTP/2 WebSocket! +``` + +TLS (wss) with your own cert/key: + +```console +# Server. +$ cd examples/http2 +$ GODEBUG=http2xconnect=1 go run . server -tls -cert cert.pem -key key.pem -addr :8443 +listening on wss://127.0.0.1:8443 (cert/key) + +# Client. +$ go run . client wss://your.host:8443 +``` + +## Structure + +The server is in `server.go` and is implemented as an `http.Handler` that +accepts a WebSocket over HTTP/2 (extended CONNECT) and echoes messages. It +supports cleartext HTTP/2 (h2c) and TLS; for TLS it can generate a self‑signed +certificate or use a provided cert/key. + +The client is in `client.go`. It dials the server over HTTP/2 (both `ws://` h2c +and `wss://` TLS), sends a single text message, and prints the echoed response. + +`main.go` wires a small CLI with `server` and `client` subcommands so you can +run and try the example quickly. diff --git a/internal/examples/http2/client.go b/internal/examples/http2/client.go new file mode 100644 index 00000000..ab64d0ba --- /dev/null +++ b/internal/examples/http2/client.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "crypto/tls" + "flag" + "fmt" + "io" + "net" + "net/http" + "net/url" + "time" + + "golang.org/x/net/http2" + + "github.com/coder/websocket" +) + +// clientMain implements a minimal HTTP/2 (extended CONNECT) WebSocket client. +// It supports: +// - ws:// using h2c (cleartext HTTP/2) +// - wss:// using TLS HTTP/2 (with optional -insecure) +func clientMain(prog string, args []string) error { + fs := flag.NewFlagSet("client", flag.ExitOnError) + insecure := fs.Bool("insecure", false, "skip TLS verification for wss://") + message := fs.String("message", "Hello over HTTP/2 WebSocket!", "message to send") + fs.Usage = func() { + fmt.Fprintf(fs.Output(), `Usage: + %[1]s client [options] + +Options: +`, prog) + fs.PrintDefaults() + fmt.Fprintf(fs.Output(), ` +Examples: + %[1]s client ws://127.0.0.1:8080 + %[1]s client -insecure wss://localhost:8443 +`, prog) + } + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() < 1 { + fs.Usage() + return fmt.Errorf("missing URL") + } + rawURL := fs.Arg(0) + + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid URL %q: %w", rawURL, err) + } + if u.Scheme != "ws" && u.Scheme != "wss" { + return fmt.Errorf("unsupported scheme %q: use ws:// or wss://", u.Scheme) + } + + // Build an HTTP/2-capable client suitable for the scheme. + var hc *http.Client + if u.Scheme == "ws" { + // Cleartext HTTP/2 (h2c). + h2t := &http2.Transport{ + AllowHTTP: true, + DialTLS: func(network, addr string, _ *tls.Config) (net.Conn, error) { + return net.Dial(network, addr) + }, + } + hc = &http.Client{Transport: h2t} + } else { + // TLS HTTP/2. + h2t := &http2.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: *insecure, + }, + } + hc = &http.Client{Transport: h2t} + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + conn, resp, err := websocket.Dial(ctx, rawURL, &websocket.DialOptions{ + HTTPClient: hc, + Protocol: websocket.ProtocolHTTP2, + }) + if err != nil { + if resp != nil { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("dial failed: %w; response: %s", err, string(b)) + } + return fmt.Errorf("dial failed: %w", err) + } + defer conn.CloseNow() + + // Send one text message and print the echoed response. + w, err := conn.Writer(ctx, websocket.MessageText) + if err != nil { + return err + } + if _, err = io.WriteString(w, *message); err != nil { + _ = w.Close() + return err + } + if err = w.Close(); err != nil { + return err + } + + typ, r, err := conn.Reader(ctx) + if err != nil { + return err + } + if typ != websocket.MessageText { + return fmt.Errorf("unexpected message type: %v", typ) + } + b, err := io.ReadAll(r) + if err != nil { + return err + } + fmt.Println(string(b)) + + _ = conn.Close(websocket.StatusNormalClosure, "bye") + return nil +} diff --git a/internal/examples/http2/main.go b/internal/examples/http2/main.go new file mode 100644 index 00000000..151a8484 --- /dev/null +++ b/internal/examples/http2/main.go @@ -0,0 +1,67 @@ +package main + +import ( + "flag" + "fmt" + "os" +) + +// main dispatches to subcommands and delegates implementation details to +// serverMain and clientMain (defined in server.go and client.go). +func main() { + prog := os.Args[0] + fs := flag.NewFlagSet(prog, flag.ExitOnError) + fs.Usage = func() { + fmt.Fprintf(fs.Output(), `Usage: + %[1]s [options] + +Options: +`, prog) + fs.PrintDefaults() + fmt.Fprintf(fs.Output(), ` +Commands: + server Run the HTTP/2 WebSocket echo server + client Connect to a server over HTTP/2 and echo a message + help Show help for a command (e.g. "%[1]s help server") + +Run: + %[1]s server -h + %[1]s client -h +for command-specific options. +`, prog) + } + err := fs.Parse(os.Args[1:]) + if err != nil { + fmt.Fprintf(os.Stderr, "error parsing flags: %v\n", err) + os.Exit(1) + } + + switch cmd := fs.Arg(0); cmd { + case "server": + if err := serverMain(prog, fs.Args()[1:]); err != nil { + fmt.Fprintf(os.Stderr, "server: error: %v\n", err) + os.Exit(1) + } + case "client": + if err := clientMain(prog, fs.Args()[1:]); err != nil { + fmt.Fprintf(os.Stderr, "client: error: %v\n", err) + os.Exit(1) + } + case "", "help": + switch fs.Arg(1) { + case "server": + _ = serverMain(prog, []string{"-h"}) + case "client": + _ = clientMain(prog, []string{"-h"}) + case "": + default: + fmt.Fprintf(os.Stderr, "error: unknown command %q\n\n", fs.Arg(1)) + } + fs.Usage() + os.Exit(1) + default: + fmt.Fprintf(os.Stderr, "error: unknown command %q\n\n", fs.Arg(0)) + fs.Usage() + os.Exit(1) + } +} diff --git a/internal/examples/http2/server.go b/internal/examples/http2/server.go new file mode 100644 index 00000000..9d362ced --- /dev/null +++ b/internal/examples/http2/server.go @@ -0,0 +1,172 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "flag" + "fmt" + "io" + "math/big" + "net/http" + "os" + "strings" + "time" + + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" + + "github.com/coder/websocket" +) + +// serverMain starts a minimal HTTP/2 WebSocket echo server. +// - By default it serves h2c (cleartext HTTP/2): ws:// +// - With -tls it serves TLS+HTTP/2: wss:// +// - If -cert and -key are not provided, a self-signed certificate is generated. +func serverMain(prog string, args []string) error { + fs := flag.NewFlagSet("server", flag.ExitOnError) + addr := fs.String("addr", ":8080", "address to listen on (host:port)") + useTLS := fs.Bool("tls", false, "enable TLS (wss://)") + certFile := fs.String("cert", "", "path to TLS certificate (PEM)") + keyFile := fs.String("key", "", "path to TLS private key (PEM)") + fs.Usage = func() { + fmt.Fprintf(fs.Output(), `Usage: + %[1]s server [options] + +Options: +`, prog) + fs.PrintDefaults() + fmt.Fprintf(fs.Output(), ` +Examples: + GODEBUG=http2xconnect=1 %[1]s server -addr :8080 + GODEBUG=http2xconnect=1 %[1]s server -tls -addr :8443 + GODEBUG=http2xconnect=1 %[1]s server -tls -cert cert.pem -key key.pem +`, prog) + } + if err := fs.Parse(args); err != nil { + return err + } + + if !strings.Contains(os.Getenv("GODEBUG"), "http2xconnect=1") { + return errors.New("http2xconnect is not enabled, please set GODEBUG=http2xconnect=1") + } + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + c, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + Protocol: websocket.ProtocolHTTP2, + }) + if err != nil { + // Accept already wrote an error response. + return + } + defer c.CloseNow() + + for { + typ, rr, err := c.Reader(ctx) + if err != nil { + // Graceful close by client. + if websocket.CloseStatus(err) == websocket.StatusNormalClosure { + return + } + return + } + ww, err := c.Writer(ctx, typ) + if err != nil { + return + } + if _, err = io.Copy(ww, rr); err != nil { + _ = ww.Close() + return + } + if err = ww.Close(); err != nil { + return + } + } + }) + + srv := &http.Server{ + Addr: *addr, + Handler: mux, + } + + if *useTLS { + // Enable HTTP/2 over TLS. + if err := http2.ConfigureServer(srv, &http2.Server{}); err != nil { + return err + } + + selfSigned := "" + if *certFile == "" && *keyFile == "" { + // No certificate provided, generate an + // ephemeral self-signed certificate. + cert, err := selfSignedRSA2048() + if err != nil { + return fmt.Errorf("generate self-signed certificate failed: %w", err) + } + srv.TLSConfig.Certificates = []tls.Certificate{cert} + selfSigned = " (self-signed)" + } + + fmt.Printf("listening on wss://%s%s\n", visibleAddr(*addr), selfSigned) + return srv.ListenAndServeTLS(*certFile, *keyFile) + } + + // Cleartext HTTP/2 (h2c). + srv.Handler = h2c.NewHandler(srv.Handler, &http2.Server{}) + fmt.Printf("listening on ws://%s (h2c)\n", visibleAddr(*addr)) + return srv.ListenAndServe() +} + +func visibleAddr(addr string) string { + // If binding to all interfaces with ":port", display "127.0.0.1:port". + if strings.HasPrefix(addr, ":") { + return "127.0.0.1" + addr + } + return addr +} + +// selfSignedRSA2048 returns an ephemeral self-signed certificate +// suitable for TLS servers. +// +// DO NOT USE IN PRODUCTION. +func selfSignedRSA2048() (tls.Certificate, error) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return tls.Certificate{}, fmt.Errorf("generate private key failed: %w", err) + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return tls.Certificate{}, fmt.Errorf("generate serial number failed: %w", err) + } + + tpl := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: "websocket-example", + Organization: []string{"websocket-example"}, + }, + DNSNames: []string{"localhost"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + der, err := x509.CreateCertificate(rand.Reader, &tpl, &tpl, &priv.PublicKey, priv) + if err != nil { + return tls.Certificate{}, fmt.Errorf("create certificate failed: %w", err) + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) + return tls.X509KeyPair(certPEM, keyPEM) +} diff --git a/internal/thirdparty/go.mod b/internal/thirdparty/go.mod index fb9c1999..cfed2e10 100644 --- a/internal/thirdparty/go.mod +++ b/internal/thirdparty/go.mod @@ -6,40 +6,39 @@ replace github.com/coder/websocket => ../.. require ( github.com/coder/websocket v0.0.0-00010101000000-000000000000 - github.com/gin-gonic/gin v1.10.0 + github.com/gin-gonic/gin v1.10.1 github.com/gobwas/ws v1.4.0 github.com/gorilla/websocket v1.5.3 - github.com/lesismal/nbio v1.5.12 + github.com/lesismal/nbio v1.6.7 + golang.org/x/net v0.44.0 ) require ( - github.com/bytedance/sonic v1.11.6 // indirect - github.com/bytedance/sonic/loader v0.1.1 // indirect - github.com/cloudwego/base64x v0.1.4 // indirect - github.com/cloudwego/iasm v0.2.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.3 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/lesismal/llib v1.1.13 // indirect + github.com/lesismal/llib v1.2.2 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.2.12 // indirect - golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.23.0 // indirect - golang.org/x/net v0.25.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect - google.golang.org/protobuf v1.34.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.42.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/thirdparty/go.sum b/internal/thirdparty/go.sum index a7be7082..9244c429 100644 --- a/internal/thirdparty/go.sum +++ b/internal/thirdparty/go.sum @@ -1,28 +1,26 @@ -github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= -github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= -github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= -github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= -github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= -github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= -github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= -github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= -github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= -github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= -github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= @@ -31,23 +29,21 @@ github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= -github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= -github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/lesismal/llib v1.1.13 h1:+w1+t0PykXpj2dXQck0+p6vdC9/mnbEXHgUy/HXDGfE= -github.com/lesismal/llib v1.1.13/go.mod h1:70tFXXe7P1FZ02AU9l8LgSOK7d7sRrpnkUr3rd3gKSg= -github.com/lesismal/nbio v1.5.12 h1:YcUjjmOvmKEANs6Oo175JogXvHy8CuE7i6ccjM2/tv4= -github.com/lesismal/nbio v1.5.12/go.mod h1:QsxE0fKFe1PioyjuHVDn2y8ktYK7xv9MFbpkoRFj8vI= +github.com/lesismal/llib v1.2.2 h1:ZoVgP9J58Ju3Yue5jtj8ybWl+BKqoVmdRaN1mNwG5Gc= +github.com/lesismal/llib v1.2.2/go.mod h1:70tFXXe7P1FZ02AU9l8LgSOK7d7sRrpnkUr3rd3gKSg= +github.com/lesismal/nbio v1.6.7 h1:EeiH0Vn0v5NG7masYNWugibPdNZZYYBPa0pGj4GOrbg= +github.com/lesismal/nbio v1.6.7/go.mod h1:mBn1rSIZ+cmOILhvP+/1Mb/JimgA+1LQudlHJUb/aNA= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -55,56 +51,47 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= -github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= -golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20210513122933-cd7d49e622d5/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= -golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/thirdparty/http2/http2_test.go b/internal/thirdparty/http2/http2_test.go new file mode 100644 index 00000000..80a1eea6 --- /dev/null +++ b/internal/thirdparty/http2/http2_test.go @@ -0,0 +1,358 @@ +package http2 + +import ( + "context" + "crypto/tls" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "regexp" + "slices" + "strings" + "testing" + "time" + + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" + + "github.com/coder/websocket" + "github.com/coder/websocket/internal/test/assert" + "github.com/coder/websocket/internal/test/wstest" +) + +// withGODEBUG re-execs the current test function with the desired http2xconnect +// setting. This is necessary because x/net/http2 reads GODEBUG at init time and +// does not notice changes made via t.Setenv. No re-execution is needed if the +// desired setting is already enabled. +func withGODEBUG(t *testing.T, wantOn bool) (hasEnv bool) { + t.Helper() + const guardEnv = "WS_RUN_HTTP2_CHILD" + if os.Getenv(guardEnv) == t.Name() { + return true + } + // Build desired GODEBUG. + var enabled bool + var filtered []string + for kv := range strings.SplitSeq(os.Getenv("GODEBUG"), ",") { + if v, ok := strings.CutPrefix(kv, "http2xconnect="); ok { + enabled = v == "1" + continue + } + filtered = append(filtered, kv) + } + if wantOn { + if enabled { + return true + } + filtered = append(filtered, "http2xconnect=1") + } else { + if !enabled { + return true + } + filtered = append(filtered, "http2xconnect=0") + } + newGODEBUG := strings.Join(filtered, ",") + + // Re-exec this test function only. + cmd := exec.Command(os.Args[0], append(slices.Clone(os.Args[1:]), []string{"-test.run", "^" + regexp.QuoteMeta(t.Name()) + "$"}...)...) + cmd.Env = append(os.Environ(), + "GODEBUG="+newGODEBUG, + guardEnv+"="+t.Name(), + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("subprocess failed for %s (GODEBUG=%s): error: %v\n%s", t.Name(), newGODEBUG, err, out) + } else { + t.Logf("subprocess succeeded for %s (GODEBUG=%s):\n%s", t.Name(), newGODEBUG, out) + } + return false +} + +// newH2TLSClient returns an *http.Client that always uses http2.Transport over +// TLS (from golang.org/x/net). For test simplicity, it disables verification. +func newH2TLSClient() *http.Client { + h2t := &http2.Transport{ + TLSClientConfig: &tls.Config{ + NextProtos: []string{http2.NextProtoTLS}, + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: true, + }, + MaxReadFrameSize: 1024 * 1024, + } + return &http.Client{Transport: h2t} +} + +type customRoundTripper struct { + http.RoundTripper + pre func(*http.Request) + post func(*http.Response) +} + +func (t *customRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if t.pre != nil { + t.pre(req) + } + resp, err := t.RoundTripper.RoundTrip(req) + if err != nil { + return nil, err + } + if t.post != nil { + t.post(resp) + } + return resp, nil +} + +// newH2TLSClientCustomRoundTripper is like newH2TLSClient but allows pre and +// post processing of requests and responses. +func newH2TLSClientCustomRoundTripper(pre func(*http.Request), post func(*http.Response)) *http.Client { + h2t := &http2.Transport{ + TLSClientConfig: &tls.Config{ + NextProtos: []string{http2.NextProtoTLS}, + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: true, + }, + MaxReadFrameSize: 1024 * 1024, + } + return &http.Client{Transport: &customRoundTripper{RoundTripper: h2t, pre: pre, post: post}} +} + +// newH2CClient returns an *http.Client that uses an http2.Transport configured +// for cleartext (h2c). (Server support for h2c is required as well.) +func newH2CClient() *http.Client { + h2t := &http2.Transport{ + AllowHTTP: true, + DialTLS: func(network, addr string, _ *tls.Config) (net.Conn, error) { + return net.Dial(network, addr) + }, + } + return &http.Client{Transport: h2t} +} + +// newH1TLSClient returns an *http.Client that forces HTTP/1.1. For test +// simplicity, it disables verification. +func newH1TLSClient() *http.Client { + h1t := &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: true, + }, + ForceAttemptHTTP2: false, + } + return &http.Client{Transport: h1t} +} + +type runTableTestCase struct { + name string + scheme string // "wss" or "ws" + client func(*testing.T) *http.Client + clientProto websocket.Protocol + serverProto websocket.Protocol + wantProto int // 1 or 2 + wantStatus int // Wanted status code (e.g., 200 or 100). + wantErr bool // Want a Dial error. +} + +// runTable executes a table of cases under the current GODEBUG mode. +func runTable(t *testing.T, cases []runTableTestCase) { + for _, tc := range cases { + echoHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{Protocol: tc.serverProto}) + if err != nil { + return + } + defer conn.CloseNow() + _ = wstest.EchoLoop(r.Context(), conn) + }) + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Start server depending on scheme and serverMode. + var srvURL string + switch tc.scheme { + case "wss": + srv := httptest.NewUnstartedServer(echoHandler) + srv.EnableHTTP2 = true + _ = http2.ConfigureServer(srv.Config, &http2.Server{}) + srv.StartTLS() + defer srv.Close() + srvURL = strings.Replace(srv.URL, "https://", "wss://", 1) + case "ws": + srv := httptest.NewUnstartedServer(h2c.NewHandler(echoHandler, &http2.Server{})) + srv.Start() + defer srv.Close() + srvURL = strings.Replace(srv.URL, "http://", "ws://", 1) + default: + t.Fatalf("unsupported scheme: %q", tc.scheme) + } + + // Perform client connect. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + conn, resp, err := websocket.Dial(ctx, srvURL, &websocket.DialOptions{ + HTTPClient: tc.client(t), + Protocol: tc.clientProto, + }) + if conn != nil { + defer conn.CloseNow() + } + + if err != nil && resp != nil { + b, _ := io.ReadAll(resp.Body) + t.Logf("Response body: %s", string(b)) + } + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + assert.Success(t, err) + + if tc.wantProto != 0 && (resp == nil || resp.ProtoMajor != tc.wantProto) { + t.Fatalf("expected HTTP/%d response, got %v", tc.wantProto, resp) + } + if tc.wantStatus != 0 && (resp == nil || resp.StatusCode != tc.wantStatus) { + t.Fatalf("expected status %d, got %v", tc.wantStatus, resp) + } + assert.Success(t, wstest.Echo(ctx, conn, 1<<10)) + }) + } +} + +var sharedTestCases = []runTableTestCase{ + { + name: "Error TLS ClientHTTP1 RequestHTTP2 AcceptAny", + scheme: "wss", + client: func(t *testing.T) *http.Client { return newH1TLSClient() }, + clientProto: websocket.ProtocolHTTP2, + serverProto: websocket.ProtocolAcceptAny, + wantErr: true, + }, + { + name: "Error H2C ClientHTTP1 RequestHTTP2 AcceptAny", + scheme: "ws", + client: func(t *testing.T) *http.Client { return newH1TLSClient() }, + clientProto: websocket.ProtocolHTTP2, + serverProto: websocket.ProtocolAcceptAny, + wantErr: true, + }, + { + name: "Error TLS ClientHTTP2 RequestHTTP2 AcceptHTTP1", + scheme: "wss", + client: func(t *testing.T) *http.Client { return newH2TLSClient() }, + clientProto: websocket.ProtocolHTTP2, + serverProto: websocket.ProtocolHTTP1, + wantErr: true, + }, + { + name: "Error TLS ClientHTTP1 RequestHTTP1 AcceptHTTP2", + scheme: "wss", + client: func(t *testing.T) *http.Client { return newH1TLSClient() }, + clientProto: websocket.ProtocolHTTP1, + serverProto: websocket.ProtocolHTTP2, + wantErr: true, + }, +} + +func TestHTTP2Suite_XCONNECT_Enabled(t *testing.T) { + if !withGODEBUG(t, true) { + return + } + runTable(t, append(sharedTestCases, []runTableTestCase{ + { + name: "OK TLS ClientHTTP2 RequestHTTP2 AcceptHTTP2", + scheme: "wss", + client: func(t *testing.T) *http.Client { return newH2TLSClient() }, + clientProto: websocket.ProtocolHTTP2, + serverProto: websocket.ProtocolHTTP2, + wantProto: 2, + }, + { + name: "OK TLS ClientHTTP2 RequestHTTP2 AcceptAny", + scheme: "wss", + client: func(t *testing.T) *http.Client { return newH2TLSClient() }, + clientProto: websocket.ProtocolHTTP2, + serverProto: websocket.ProtocolAcceptAny, + wantProto: 2, + }, + { + name: "OK H2C ClientHTTP2 RequestHTTP2 AcceptAny", + scheme: "ws", + client: func(t *testing.T) *http.Client { return newH2CClient() }, + clientProto: websocket.ProtocolHTTP2, + serverProto: websocket.ProtocolAcceptAny, + wantProto: 2, + }, + { + name: "Error server missing :protocol header", + scheme: "wss", + client: func(t *testing.T) *http.Client { + return newH2TLSClientCustomRoundTripper(func(req *http.Request) { + req.Header.Del(":protocol") + }, nil) + }, + clientProto: websocket.ProtocolHTTP2, + serverProto: websocket.ProtocolHTTP2, + wantErr: true, + }, + { + // Note that this test actually fails inside the `http2` package and + // does not reach our validation routine on the server side. + // + // http2: invalid :protocol header in non-CONNECT request + name: "Error server method not CONNECT", + scheme: "wss", + client: func(t *testing.T) *http.Client { + return newH2TLSClientCustomRoundTripper(func(req *http.Request) { + req.Method = http.MethodGet + }, nil) + }, + clientProto: websocket.ProtocolHTTP2, + serverProto: websocket.ProtocolHTTP2, + wantErr: true, + }, + { + name: "Error dial non-2xx response", + scheme: "wss", + client: func(t *testing.T) *http.Client { + return newH2TLSClientCustomRoundTripper(nil, func(resp *http.Response) { + resp.StatusCode = http.StatusSwitchingProtocols + // Close body so we don't hang since this is actually a + // CONNECT response. + _ = resp.Body.Close() + }) + }, + clientProto: websocket.ProtocolHTTP2, + serverProto: websocket.ProtocolHTTP2, + wantErr: true, + }, + }...)) +} + +func TestHTTP2Suite_XCONNECT_Disabled(t *testing.T) { + if !withGODEBUG(t, false) { + return + } + runTable(t, append(sharedTestCases, []runTableTestCase{ + { + name: "Error TLS ClientHTTP2 RequestHTTP2 AcceptAny NoExtendedConnect", + scheme: "wss", + client: func(t *testing.T) *http.Client { return newH2TLSClient() }, + clientProto: websocket.ProtocolHTTP2, + serverProto: websocket.ProtocolAcceptAny, + wantErr: true, + }, + { + name: "Error H2C ClientHTTP2 RequestHTTP2 AcceptAny NoExtendedConnect", + scheme: "ws", + client: func(t *testing.T) *http.Client { return newH2CClient() }, + clientProto: websocket.ProtocolHTTP2, + serverProto: websocket.ProtocolAcceptAny, + wantErr: true, + }, + }...)) +} diff --git a/protocol.go b/protocol.go new file mode 100644 index 00000000..43453fae --- /dev/null +++ b/protocol.go @@ -0,0 +1,55 @@ +package websocket + +import "fmt" + +// Protocol selects the HTTP version used for the WebSocket handshake. +// +// This type is used by both the client (Dial) and the server (Accept). +// +// Defaults and compatibility: +// - The zero value is ProtocolHTTP1 to preserve existing behavior. +// - HTTP/2 is NOT enabled by default. +// +// Client (Dial) semantics: +// - ProtocolHTTP1: perform an HTTP/1.1 Upgrade handshake. +// - ProtocolHTTP2: perform an HTTP/2 extended CONNECT (RFC 8441) handshake. +// - ProtocolAcceptAny: not supported for clients. +// +// Server (Accept) semantics: +// - ProtocolHTTP1: accept only HTTP/1.1 Upgrade handshakes. +// - ProtocolHTTP2: accept only HTTP/2 extended CONNECT (RFC 8441) handshakes. +// - ProtocolAcceptAny: accept either HTTP/1.1 Upgrade or HTTP/2 extended CONNECT. +// +// For HTTP/2 client dialing, callers must supply an http.Client configured with +// an http2.Transport (from golang.org/x/net/http2). +// +// Experimental: This type is experimental and may change in the future. +type Protocol int + +const ( + // ProtocolAcceptAny accepts either HTTP/1.1 Upgrade or HTTP/2 extended + // CONNECT. Valid only for servers (Accept). This value is rejected by + // clients (Dial). + ProtocolAcceptAny Protocol = iota - 1 + + // ProtocolHTTP1 selects HTTP/1.1 GET+Upgrade for the WebSocket handshake. + // This is the default (zero value). + ProtocolHTTP1 + + // ProtocolHTTP2 selects HTTP/2 extended CONNECT (RFC 8441) for the handshake. + ProtocolHTTP2 +) + +// String implements fmt.Stringer. +func (p Protocol) String() string { + switch p { + case ProtocolHTTP1: + return "ProtocolHTTP1" + case ProtocolHTTP2: + return "ProtocolHTTP2" + case ProtocolAcceptAny: + return "ProtocolAcceptAny" + default: + return fmt.Sprintf("Protocol(%d)", p) + } +} diff --git a/ws_js.go b/ws_js.go index 026b75fc..52d4abe4 100644 --- a/ws_js.go +++ b/ws_js.go @@ -281,6 +281,10 @@ func (c *Conn) Subprotocol() string { type DialOptions struct { // Subprotocols lists the subprotocols to negotiate with the server. Subprotocols []string + + // Protocol selects the HTTP version for the handshake on native builds. + // No-op in Wasm. + Protocol Protocol } // Dial creates a new WebSocket connection to the given url with the given options. @@ -439,6 +443,10 @@ type AcceptOptions struct { OriginPatterns []string CompressionMode CompressionMode CompressionThreshold int + + // Protocol selects which HTTP version to accept on native builds. + // No-op in Wasm. + Protocol Protocol } // Accept is stubbed out for Wasm. From 6d09ea68ab343d21a374c864ccb5214ff54bfb35 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 16 Dec 2025 16:38:37 +0000 Subject: [PATCH 02/19] fix: address review feedback - Fix 'exampels' typo in CI workflow comments - Fix README paths to use 'internal/examples/http2' - Change visibleAddr to display 0.0.0.0 for all-interface bindings --- internal/examples/http2/README.md | 6 +++--- internal/examples/http2/server.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/examples/http2/README.md b/internal/examples/http2/README.md index c80951ab..77e40840 100644 --- a/internal/examples/http2/README.md +++ b/internal/examples/http2/README.md @@ -14,7 +14,7 @@ Cleartext HTTP/2: ```console # Server. -$ cd examples/http2 +$ cd internal/examples/http2 $ GODEBUG=http2xconnect=1 go run . server -addr :8080 listening on ws://127.0.0.1:8080 (h2c) @@ -27,7 +27,7 @@ TLS (wss) with self-signed cert: ```console # Server. -$ cd examples/http2 +$ cd internal/examples/http2 $ GODEBUG=http2xconnect=1 go run . server -tls -addr :8443 listening on wss://127.0.0.1:8443 (self-signed) @@ -40,7 +40,7 @@ TLS (wss) with your own cert/key: ```console # Server. -$ cd examples/http2 +$ cd internal/examples/http2 $ GODEBUG=http2xconnect=1 go run . server -tls -cert cert.pem -key key.pem -addr :8443 listening on wss://127.0.0.1:8443 (cert/key) diff --git a/internal/examples/http2/server.go b/internal/examples/http2/server.go index 9d362ced..83568969 100644 --- a/internal/examples/http2/server.go +++ b/internal/examples/http2/server.go @@ -125,9 +125,9 @@ Examples: } func visibleAddr(addr string) string { - // If binding to all interfaces with ":port", display "127.0.0.1:port". + // If binding to all interfaces with ":port", display "0.0.0.0:port". if strings.HasPrefix(addr, ":") { - return "127.0.0.1" + addr + return "0.0.0.0" + addr } return addr } From fea5b67b58a76e1360aa6b87432bce828cfd5f65 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 16 Dec 2025 18:51:56 +0000 Subject: [PATCH 03/19] refactor: rename Protocol to HTTPProtocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the Protocol type to HTTPProtocol for consistency with existing HTTPClient and HTTPHeader field naming in DialOptions. Constants are also renamed: - ProtocolHTTP1 → HTTPProtocol1 - ProtocolHTTP2 → HTTPProtocol2 - ProtocolAcceptAny → HTTPProtocolAny --- accept.go | 38 ++++++++-------- accept_test.go | 2 +- dial.go | 28 ++++++------ doc.go | 2 +- internal/examples/http2/client.go | 2 +- internal/thirdparty/http2/http2_test.go | 58 ++++++++++++------------- protocol.go | 46 ++++++++++---------- ws_js.go | 8 ++-- 8 files changed, 92 insertions(+), 92 deletions(-) diff --git a/accept.go b/accept.go index 9debc666..2098e4cd 100644 --- a/accept.go +++ b/accept.go @@ -27,12 +27,12 @@ type AcceptOptions struct { // reject it, close the connection when c.Subprotocol() == "". Subprotocols []string - // Protocol selects which HTTP version to accept. Zero value defaults to - // ProtocolHTTP1. ProtocolAcceptAny allows accepting either HTTP/1.1 or + // HTTPProtocol selects which HTTP version to accept. Zero value defaults to + // HTTPProtocol1. HTTPProtocolAny allows accepting either HTTP/1.1 or // HTTP/2. // // Experimental: This feature is experimental and may change in the future. - Protocol Protocol + HTTPProtocol HTTPProtocol // InsecureSkipVerify is used to disable Accept's origin verification behavior. // @@ -95,10 +95,10 @@ func (opts *AcceptOptions) cloneWithDefaults() (*AcceptOptions, error) { } // Defaults to HTTP/1.1 only to preserve existing behavior (zero value). - switch o.Protocol { - case ProtocolAcceptAny, ProtocolHTTP1, ProtocolHTTP2: + switch o.HTTPProtocol { + case HTTPProtocolAny, HTTPProtocol1, HTTPProtocol2: default: - return nil, fmt.Errorf("websocket: invalid protocol for accept options: %s", o.Protocol) + return nil, fmt.Errorf("websocket: invalid protocol for accept options: %s", o.HTTPProtocol) } return &o, nil @@ -148,7 +148,7 @@ func accept(w http.ResponseWriter, r *http.Request, opts *AcceptOptions) (_ *Con } switch proto { - case ProtocolHTTP2: + case HTTPProtocol2: // Prepare response headers for H2 (no Connection/Upgrade). w.Header().Set("Sec-WebSocket-Accept", secWebSocketAccept(key)) @@ -184,7 +184,7 @@ func accept(w http.ResponseWriter, r *http.Request, opts *AcceptOptions) (_ *Con bw: getBufioWriter(stream), }), nil - case ProtocolHTTP1: + case HTTPProtocol1: hj, ok := hijacker(w) if !ok { err = errors.New("http.ResponseWriter does not implement http.Hijacker") @@ -243,32 +243,32 @@ func accept(w http.ResponseWriter, r *http.Request, opts *AcceptOptions) (_ *Con } } -func verifyClientRequest(w http.ResponseWriter, r *http.Request, opts *AcceptOptions) (proto Protocol, key string, errCode int, err error) { +func verifyClientRequest(w http.ResponseWriter, r *http.Request, opts *AcceptOptions) (proto HTTPProtocol, key string, errCode int, err error) { if r.ProtoMajor == 2 { - switch opts.Protocol { - case ProtocolHTTP1: - return ProtocolHTTP2, "", http.StatusBadRequest, errors.New("HTTP/2 extended CONNECT refused: server only accepts HTTP/1.1 Upgrade") + switch opts.HTTPProtocol { + case HTTPProtocol1: + return HTTPProtocol2, "", http.StatusBadRequest, errors.New("HTTP/2 extended CONNECT refused: server only accepts HTTP/1.1 Upgrade") } // HTTP/2 extended CONNECT (RFC 8441) path. key, errCode, err = verifyClientRequestH2(w, r) if err != nil { - return ProtocolHTTP2, "", errCode, err + return HTTPProtocol2, "", errCode, err } - return ProtocolHTTP2, key, 0, nil + return HTTPProtocol2, key, 0, nil } - switch opts.Protocol { - case ProtocolHTTP2: - return ProtocolHTTP1, "", http.StatusBadRequest, errors.New("HTTP/1.1 Upgrade refused: server requires HTTP/2 extended CONNECT") + switch opts.HTTPProtocol { + case HTTPProtocol2: + return HTTPProtocol1, "", http.StatusBadRequest, errors.New("HTTP/1.1 Upgrade refused: server requires HTTP/2 extended CONNECT") } // HTTP/1.1 GET/Upgrade handshake validation. key, errCode, err = verifyClientRequestH1(w, r) if err != nil { - return ProtocolHTTP1, "", errCode, err + return HTTPProtocol1, "", errCode, err } - return ProtocolHTTP1, key, 0, nil + return HTTPProtocol1, key, 0, nil } // verifyClientRequestH1 validates an HTTP/1.1 WebSocket GET/Upgrade request. diff --git a/accept_test.go b/accept_test.go index 0718a347..4ee94e04 100644 --- a/accept_test.go +++ b/accept_test.go @@ -342,7 +342,7 @@ func Test_verifyClientHandshake(t *testing.T) { r.Header.Add(k, v) } - _, _, _, err := verifyClientRequest(httptest.NewRecorder(), r, &AcceptOptions{Protocol: ProtocolHTTP1}) + _, _, _, err := verifyClientRequest(httptest.NewRecorder(), r, &AcceptOptions{HTTPProtocol: HTTPProtocol1}) if tc.success { assert.Success(t, err) } else { diff --git a/dial.go b/dial.go index 1f304306..da7df341 100644 --- a/dial.go +++ b/dial.go @@ -29,11 +29,11 @@ type DialOptions struct { // HTTPHeader specifies the HTTP headers included in the handshake request. HTTPHeader http.Header - // Protocol selects the HTTP version for the handshake. Zero value defaults - // to ProtocolHTTP1. ProtocolAcceptAny is not supported by Dial. + // HTTPProtocol selects the HTTP version for the handshake. Zero value defaults + // to HTTPProtocol1. HTTPProtocolAny is not supported by Dial. // // Experimental: This feature is experimental and may change in the future. - Protocol Protocol + HTTPProtocol HTTPProtocol // Host optionally overrides the Host HTTP header to send. If empty, the value // of URL.Host will be used. @@ -80,10 +80,10 @@ func (opts *DialOptions) cloneWithDefaults(ctx context.Context) (context.Context } // Defaults to HTTP/1.1 only to preserve existing behavior (zero value). - switch o.Protocol { - case ProtocolHTTP1, ProtocolHTTP2: + switch o.HTTPProtocol { + case HTTPProtocol1, HTTPProtocol2: default: - return nil, nil, nil, fmt.Errorf("websocket: invalid protocol for dial options: %s", o.Protocol) + return nil, nil, nil, fmt.Errorf("websocket: invalid protocol for dial options: %s", o.HTTPProtocol) } if o.HTTPClient == nil { @@ -218,13 +218,13 @@ func handshakeRequest(ctx context.Context, urls string, opts *DialOptions, copts return nil, fmt.Errorf("unexpected url scheme: %q", u.Scheme) } - switch opts.Protocol { - case ProtocolHTTP2: + switch opts.HTTPProtocol { + case HTTPProtocol2: return handshakeRequestH2(ctx, u, opts, copts, secWebSocketKey) - case ProtocolHTTP1: + case HTTPProtocol1: return handshakeRequestH1(ctx, u, opts, copts, secWebSocketKey) default: - return nil, fmt.Errorf("unknown protocol: %s", opts.Protocol) + return nil, fmt.Errorf("unknown protocol: %s", opts.HTTPProtocol) } } @@ -317,13 +317,13 @@ func secWebSocketKey(rr io.Reader) (string, error) { } func verifyServerResponse(opts *DialOptions, copts *compressionOptions, secWebSocketKey string, resp *http.Response) (*compressionOptions, error) { - switch opts.Protocol { - case ProtocolHTTP2: + switch opts.HTTPProtocol { + case HTTPProtocol2: return verifyServerResponseH2(opts, copts, secWebSocketKey, resp) - case ProtocolHTTP1: + case HTTPProtocol1: return verifyServerResponseH1(opts, copts, secWebSocketKey, resp) default: - return nil, fmt.Errorf("unknown protocol: %s", opts.Protocol) + return nil, fmt.Errorf("unknown protocol: %s", opts.HTTPProtocol) } } diff --git a/doc.go b/doc.go index c3c07251..4397253b 100644 --- a/doc.go +++ b/doc.go @@ -41,7 +41,7 @@ // Some important caveats to be aware of: // // - Accept always errors out -// - Protocol in DialOptions and AcceptOptions is no-op +// - HTTPProtocol in DialOptions and AcceptOptions is no-op // - HTTPClient, HTTPHeader and CompressionMode in DialOptions are no-op // - *http.Response from Dial is &http.Response{} with a 101 status code on success // - Conn.Ping is no-op diff --git a/internal/examples/http2/client.go b/internal/examples/http2/client.go index ab64d0ba..e3e74311 100644 --- a/internal/examples/http2/client.go +++ b/internal/examples/http2/client.go @@ -81,7 +81,7 @@ Examples: conn, resp, err := websocket.Dial(ctx, rawURL, &websocket.DialOptions{ HTTPClient: hc, - Protocol: websocket.ProtocolHTTP2, + HTTPProtocol: websocket.HTTPProtocol2, }) if err != nil { if resp != nil { diff --git a/internal/thirdparty/http2/http2_test.go b/internal/thirdparty/http2/http2_test.go index 80a1eea6..d02851f8 100644 --- a/internal/thirdparty/http2/http2_test.go +++ b/internal/thirdparty/http2/http2_test.go @@ -148,8 +148,8 @@ type runTableTestCase struct { name string scheme string // "wss" or "ws" client func(*testing.T) *http.Client - clientProto websocket.Protocol - serverProto websocket.Protocol + clientProto websocket.HTTPProtocol + serverProto websocket.HTTPProtocol wantProto int // 1 or 2 wantStatus int // Wanted status code (e.g., 200 or 100). wantErr bool // Want a Dial error. @@ -159,7 +159,7 @@ type runTableTestCase struct { func runTable(t *testing.T, cases []runTableTestCase) { for _, tc := range cases { echoHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{Protocol: tc.serverProto}) + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{HTTPProtocol: tc.serverProto}) if err != nil { return } @@ -193,8 +193,8 @@ func runTable(t *testing.T, cases []runTableTestCase) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() conn, resp, err := websocket.Dial(ctx, srvURL, &websocket.DialOptions{ - HTTPClient: tc.client(t), - Protocol: tc.clientProto, + HTTPClient: tc.client(t), + HTTPProtocol: tc.clientProto, }) if conn != nil { defer conn.CloseNow() @@ -228,32 +228,32 @@ var sharedTestCases = []runTableTestCase{ name: "Error TLS ClientHTTP1 RequestHTTP2 AcceptAny", scheme: "wss", client: func(t *testing.T) *http.Client { return newH1TLSClient() }, - clientProto: websocket.ProtocolHTTP2, - serverProto: websocket.ProtocolAcceptAny, + clientProto: websocket.HTTPProtocol2, + serverProto: websocket.HTTPProtocolAny, wantErr: true, }, { name: "Error H2C ClientHTTP1 RequestHTTP2 AcceptAny", scheme: "ws", client: func(t *testing.T) *http.Client { return newH1TLSClient() }, - clientProto: websocket.ProtocolHTTP2, - serverProto: websocket.ProtocolAcceptAny, + clientProto: websocket.HTTPProtocol2, + serverProto: websocket.HTTPProtocolAny, wantErr: true, }, { name: "Error TLS ClientHTTP2 RequestHTTP2 AcceptHTTP1", scheme: "wss", client: func(t *testing.T) *http.Client { return newH2TLSClient() }, - clientProto: websocket.ProtocolHTTP2, - serverProto: websocket.ProtocolHTTP1, + clientProto: websocket.HTTPProtocol2, + serverProto: websocket.HTTPProtocol1, wantErr: true, }, { name: "Error TLS ClientHTTP1 RequestHTTP1 AcceptHTTP2", scheme: "wss", client: func(t *testing.T) *http.Client { return newH1TLSClient() }, - clientProto: websocket.ProtocolHTTP1, - serverProto: websocket.ProtocolHTTP2, + clientProto: websocket.HTTPProtocol1, + serverProto: websocket.HTTPProtocol2, wantErr: true, }, } @@ -267,24 +267,24 @@ func TestHTTP2Suite_XCONNECT_Enabled(t *testing.T) { name: "OK TLS ClientHTTP2 RequestHTTP2 AcceptHTTP2", scheme: "wss", client: func(t *testing.T) *http.Client { return newH2TLSClient() }, - clientProto: websocket.ProtocolHTTP2, - serverProto: websocket.ProtocolHTTP2, + clientProto: websocket.HTTPProtocol2, + serverProto: websocket.HTTPProtocol2, wantProto: 2, }, { name: "OK TLS ClientHTTP2 RequestHTTP2 AcceptAny", scheme: "wss", client: func(t *testing.T) *http.Client { return newH2TLSClient() }, - clientProto: websocket.ProtocolHTTP2, - serverProto: websocket.ProtocolAcceptAny, + clientProto: websocket.HTTPProtocol2, + serverProto: websocket.HTTPProtocolAny, wantProto: 2, }, { name: "OK H2C ClientHTTP2 RequestHTTP2 AcceptAny", scheme: "ws", client: func(t *testing.T) *http.Client { return newH2CClient() }, - clientProto: websocket.ProtocolHTTP2, - serverProto: websocket.ProtocolAcceptAny, + clientProto: websocket.HTTPProtocol2, + serverProto: websocket.HTTPProtocolAny, wantProto: 2, }, { @@ -295,8 +295,8 @@ func TestHTTP2Suite_XCONNECT_Enabled(t *testing.T) { req.Header.Del(":protocol") }, nil) }, - clientProto: websocket.ProtocolHTTP2, - serverProto: websocket.ProtocolHTTP2, + clientProto: websocket.HTTPProtocol2, + serverProto: websocket.HTTPProtocol2, wantErr: true, }, { @@ -311,8 +311,8 @@ func TestHTTP2Suite_XCONNECT_Enabled(t *testing.T) { req.Method = http.MethodGet }, nil) }, - clientProto: websocket.ProtocolHTTP2, - serverProto: websocket.ProtocolHTTP2, + clientProto: websocket.HTTPProtocol2, + serverProto: websocket.HTTPProtocol2, wantErr: true, }, { @@ -326,8 +326,8 @@ func TestHTTP2Suite_XCONNECT_Enabled(t *testing.T) { _ = resp.Body.Close() }) }, - clientProto: websocket.ProtocolHTTP2, - serverProto: websocket.ProtocolHTTP2, + clientProto: websocket.HTTPProtocol2, + serverProto: websocket.HTTPProtocol2, wantErr: true, }, }...)) @@ -342,16 +342,16 @@ func TestHTTP2Suite_XCONNECT_Disabled(t *testing.T) { name: "Error TLS ClientHTTP2 RequestHTTP2 AcceptAny NoExtendedConnect", scheme: "wss", client: func(t *testing.T) *http.Client { return newH2TLSClient() }, - clientProto: websocket.ProtocolHTTP2, - serverProto: websocket.ProtocolAcceptAny, + clientProto: websocket.HTTPProtocol2, + serverProto: websocket.HTTPProtocolAny, wantErr: true, }, { name: "Error H2C ClientHTTP2 RequestHTTP2 AcceptAny NoExtendedConnect", scheme: "ws", client: func(t *testing.T) *http.Client { return newH2CClient() }, - clientProto: websocket.ProtocolHTTP2, - serverProto: websocket.ProtocolAcceptAny, + clientProto: websocket.HTTPProtocol2, + serverProto: websocket.HTTPProtocolAny, wantErr: true, }, }...)) diff --git a/protocol.go b/protocol.go index 43453fae..81a4b17a 100644 --- a/protocol.go +++ b/protocol.go @@ -2,54 +2,54 @@ package websocket import "fmt" -// Protocol selects the HTTP version used for the WebSocket handshake. +// HTTPProtocol selects the HTTP version used for the WebSocket handshake. // // This type is used by both the client (Dial) and the server (Accept). // // Defaults and compatibility: -// - The zero value is ProtocolHTTP1 to preserve existing behavior. +// - The zero value is HTTPProtocol1 to preserve existing behavior. // - HTTP/2 is NOT enabled by default. // // Client (Dial) semantics: -// - ProtocolHTTP1: perform an HTTP/1.1 Upgrade handshake. -// - ProtocolHTTP2: perform an HTTP/2 extended CONNECT (RFC 8441) handshake. -// - ProtocolAcceptAny: not supported for clients. +// - HTTPProtocol1: perform an HTTP/1.1 Upgrade handshake. +// - HTTPProtocol2: perform an HTTP/2 extended CONNECT (RFC 8441) handshake. +// - HTTPProtocolAny: not supported for clients. // // Server (Accept) semantics: -// - ProtocolHTTP1: accept only HTTP/1.1 Upgrade handshakes. -// - ProtocolHTTP2: accept only HTTP/2 extended CONNECT (RFC 8441) handshakes. -// - ProtocolAcceptAny: accept either HTTP/1.1 Upgrade or HTTP/2 extended CONNECT. +// - HTTPProtocol1: accept only HTTP/1.1 Upgrade handshakes. +// - HTTPProtocol2: accept only HTTP/2 extended CONNECT (RFC 8441) handshakes. +// - HTTPProtocolAny: accept either HTTP/1.1 Upgrade or HTTP/2 extended CONNECT. // // For HTTP/2 client dialing, callers must supply an http.Client configured with // an http2.Transport (from golang.org/x/net/http2). // // Experimental: This type is experimental and may change in the future. -type Protocol int +type HTTPProtocol int const ( - // ProtocolAcceptAny accepts either HTTP/1.1 Upgrade or HTTP/2 extended + // HTTPProtocolAny accepts either HTTP/1.1 Upgrade or HTTP/2 extended // CONNECT. Valid only for servers (Accept). This value is rejected by // clients (Dial). - ProtocolAcceptAny Protocol = iota - 1 + HTTPProtocolAny HTTPProtocol = iota - 1 - // ProtocolHTTP1 selects HTTP/1.1 GET+Upgrade for the WebSocket handshake. + // HTTPProtocol1 selects HTTP/1.1 GET+Upgrade for the WebSocket handshake. // This is the default (zero value). - ProtocolHTTP1 + HTTPProtocol1 - // ProtocolHTTP2 selects HTTP/2 extended CONNECT (RFC 8441) for the handshake. - ProtocolHTTP2 + // HTTPProtocol2 selects HTTP/2 extended CONNECT (RFC 8441) for the handshake. + HTTPProtocol2 ) // String implements fmt.Stringer. -func (p Protocol) String() string { +func (p HTTPProtocol) String() string { switch p { - case ProtocolHTTP1: - return "ProtocolHTTP1" - case ProtocolHTTP2: - return "ProtocolHTTP2" - case ProtocolAcceptAny: - return "ProtocolAcceptAny" + case HTTPProtocol1: + return "HTTPProtocol1" + case HTTPProtocol2: + return "HTTPProtocol2" + case HTTPProtocolAny: + return "HTTPProtocolAny" default: - return fmt.Sprintf("Protocol(%d)", p) + return fmt.Sprintf("HTTPProtocol(%d)", p) } } diff --git a/ws_js.go b/ws_js.go index 52d4abe4..615808e9 100644 --- a/ws_js.go +++ b/ws_js.go @@ -282,9 +282,9 @@ type DialOptions struct { // Subprotocols lists the subprotocols to negotiate with the server. Subprotocols []string - // Protocol selects the HTTP version for the handshake on native builds. + // HTTPProtocol selects the HTTP version for the handshake on native builds. // No-op in Wasm. - Protocol Protocol + HTTPProtocol HTTPProtocol } // Dial creates a new WebSocket connection to the given url with the given options. @@ -444,9 +444,9 @@ type AcceptOptions struct { CompressionMode CompressionMode CompressionThreshold int - // Protocol selects which HTTP version to accept on native builds. + // HTTPProtocol selects which HTTP version to accept on native builds. // No-op in Wasm. - Protocol Protocol + HTTPProtocol HTTPProtocol } // Accept is stubbed out for Wasm. From 53a900c7713bf73c102442f6f55c35368061a719 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 16 Dec 2025 18:52:02 +0000 Subject: [PATCH 04/19] refactor(internal/examples/http2): remove self-signed cert generation Simplify the TLS example by requiring -cert and -key flags instead of auto-generating a self-signed certificate. This reduces complexity and keeps the example focused on HTTP/2 WebSocket usage. --- internal/examples/http2/README.md | 29 ++++-------- internal/examples/http2/server.go | 73 ++++--------------------------- 2 files changed, 16 insertions(+), 86 deletions(-) diff --git a/internal/examples/http2/README.md b/internal/examples/http2/README.md index 77e40840..5dd845db 100644 --- a/internal/examples/http2/README.md +++ b/internal/examples/http2/README.md @@ -6,7 +6,7 @@ This example shows a minimal WebSocket echo over HTTP/2 using extended CONNECT It supports: - h2c (cleartext HTTP/2) via `ws://` -- TLS + HTTP/2 via `wss://` (self-signed by default, or bring your own cert/key) +- TLS + HTTP/2 via `wss://` (requires cert and key) ## Run @@ -16,47 +16,34 @@ Cleartext HTTP/2: # Server. $ cd internal/examples/http2 $ GODEBUG=http2xconnect=1 go run . server -addr :8080 -listening on ws://127.0.0.1:8080 (h2c) +listening on ws://0.0.0.0:8080 (h2c) # Client. $ go run . client ws://127.0.0.1:8080 Hello over HTTP/2 WebSocket! ``` -TLS (wss) with self-signed cert: - -```console -# Server. -$ cd internal/examples/http2 -$ GODEBUG=http2xconnect=1 go run . server -tls -addr :8443 -listening on wss://127.0.0.1:8443 (self-signed) - -# Client. -$ go run . client -insecure wss://localhost:8443 -Hello over HTTP/2 WebSocket! -``` - -TLS (wss) with your own cert/key: +TLS (wss): ```console # Server. $ cd internal/examples/http2 $ GODEBUG=http2xconnect=1 go run . server -tls -cert cert.pem -key key.pem -addr :8443 -listening on wss://127.0.0.1:8443 (cert/key) +listening on wss://0.0.0.0:8443 # Client. -$ go run . client wss://your.host:8443 +$ go run . client wss://localhost:8443 +Hello over HTTP/2 WebSocket! ``` ## Structure The server is in `server.go` and is implemented as an `http.Handler` that accepts a WebSocket over HTTP/2 (extended CONNECT) and echoes messages. It -supports cleartext HTTP/2 (h2c) and TLS; for TLS it can generate a self‑signed -certificate or use a provided cert/key. +supports cleartext HTTP/2 (h2c) and TLS. The client is in `client.go`. It dials the server over HTTP/2 (both `ws://` h2c and `wss://` TLS), sends a single text message, and prints the echoed response. `main.go` wires a small CLI with `server` and `client` subcommands so you can -run and try the example quickly. +run and try the example quickly. \ No newline at end of file diff --git a/internal/examples/http2/server.go b/internal/examples/http2/server.go index 83568969..f419f098 100644 --- a/internal/examples/http2/server.go +++ b/internal/examples/http2/server.go @@ -1,21 +1,13 @@ package main import ( - "crypto/rand" - "crypto/rsa" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" "errors" "flag" "fmt" "io" - "math/big" "net/http" "os" "strings" - "time" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" @@ -25,8 +17,7 @@ import ( // serverMain starts a minimal HTTP/2 WebSocket echo server. // - By default it serves h2c (cleartext HTTP/2): ws:// -// - With -tls it serves TLS+HTTP/2: wss:// -// - If -cert and -key are not provided, a self-signed certificate is generated. +// - With -tls it serves TLS+HTTP/2: wss:// (requires -cert and -key) func serverMain(prog string, args []string) error { fs := flag.NewFlagSet("server", flag.ExitOnError) addr := fs.String("addr", ":8080", "address to listen on (host:port)") @@ -43,8 +34,7 @@ Options: fmt.Fprintf(fs.Output(), ` Examples: GODEBUG=http2xconnect=1 %[1]s server -addr :8080 - GODEBUG=http2xconnect=1 %[1]s server -tls -addr :8443 - GODEBUG=http2xconnect=1 %[1]s server -tls -cert cert.pem -key key.pem + GODEBUG=http2xconnect=1 %[1]s server -tls -cert cert.pem -key key.pem -addr :8443 `, prog) } if err := fs.Parse(args); err != nil { @@ -60,7 +50,7 @@ Examples: ctx := r.Context() c, err := websocket.Accept(w, r, &websocket.AcceptOptions{ - Protocol: websocket.ProtocolHTTP2, + HTTPProtocol: websocket.HTTPProtocol2, }) if err != nil { // Accept already wrote an error response. @@ -97,24 +87,16 @@ Examples: } if *useTLS { + if *certFile == "" || *keyFile == "" { + return errors.New("-cert and -key are required when using -tls") + } + // Enable HTTP/2 over TLS. if err := http2.ConfigureServer(srv, &http2.Server{}); err != nil { return err } - selfSigned := "" - if *certFile == "" && *keyFile == "" { - // No certificate provided, generate an - // ephemeral self-signed certificate. - cert, err := selfSignedRSA2048() - if err != nil { - return fmt.Errorf("generate self-signed certificate failed: %w", err) - } - srv.TLSConfig.Certificates = []tls.Certificate{cert} - selfSigned = " (self-signed)" - } - - fmt.Printf("listening on wss://%s%s\n", visibleAddr(*addr), selfSigned) + fmt.Printf("listening on wss://%s\n", visibleAddr(*addr)) return srv.ListenAndServeTLS(*certFile, *keyFile) } @@ -131,42 +113,3 @@ func visibleAddr(addr string) string { } return addr } - -// selfSignedRSA2048 returns an ephemeral self-signed certificate -// suitable for TLS servers. -// -// DO NOT USE IN PRODUCTION. -func selfSignedRSA2048() (tls.Certificate, error) { - priv, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return tls.Certificate{}, fmt.Errorf("generate private key failed: %w", err) - } - - serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) - if err != nil { - return tls.Certificate{}, fmt.Errorf("generate serial number failed: %w", err) - } - - tpl := x509.Certificate{ - SerialNumber: serial, - Subject: pkix.Name{ - CommonName: "websocket-example", - Organization: []string{"websocket-example"}, - }, - DNSNames: []string{"localhost"}, - NotBefore: time.Now().Add(-time.Hour), - NotAfter: time.Now().Add(365 * 24 * time.Hour), - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - BasicConstraintsValid: true, - } - - der, err := x509.CreateCertificate(rand.Reader, &tpl, &tpl, &priv.PublicKey, priv) - if err != nil { - return tls.Certificate{}, fmt.Errorf("create certificate failed: %w", err) - } - - certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) - keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) - return tls.X509KeyPair(certPEM, keyPEM) -} From fd5e7f7c544713d5e094c00cfa00a9249ae84c9a Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 29 Oct 2026 15:48:21 +0200 Subject: [PATCH 05/19] fix(dial): verify HTTP/1.x protocol in verifyServerResponseH1 (#550) Add resp.ProtoMajor check in verifyServerResponseH1 for consistency with verifyServerResponseH2 which already performs this check. Refs #539 --- dial.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dial.go b/dial.go index da7df341..55256613 100644 --- a/dial.go +++ b/dial.go @@ -328,6 +328,10 @@ func verifyServerResponse(opts *DialOptions, copts *compressionOptions, secWebSo } func verifyServerResponseH1(opts *DialOptions, copts *compressionOptions, secWebSocketKey string, resp *http.Response) (*compressionOptions, error) { + if resp.ProtoMajor != 1 { + return nil, fmt.Errorf("expected HTTP/1.x response but got: %s", resp.Proto) + } + if resp.StatusCode != http.StatusSwitchingProtocols { return nil, fmt.Errorf("expected handshake response status code %v but got %v", http.StatusSwitchingProtocols, resp.StatusCode) } From 3866540488a714d2ba6934c35b237abaae3f1277 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 29 Oct 2026 23:17:46 +0000 Subject: [PATCH 06/19] test(dial): set ProtoMajor in hand-built responses verifyServerResponseH1 rejects responses whose ProtoMajor is not 1. These mock responses left it at 0, so each dial failed before the check its test targets. Responses from net/http always set ProtoMajor. --- dial_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/dial_test.go b/dial_test.go index 492ac6b3..bf32ce3b 100644 --- a/dial_test.go +++ b/dial_test.go @@ -90,7 +90,8 @@ func TestBadDials(t *testing.T) { _, _, err := websocket.Dial(ctx, "ws://example.com", &websocket.DialOptions{ HTTPClient: mockHTTPClient(func(*http.Request) (*http.Response, error) { return &http.Response{ - Body: io.NopCloser(strings.NewReader("hi")), + ProtoMajor: 1, + Body: io.NopCloser(strings.NewReader("hi")), }, nil }), }) @@ -110,6 +111,7 @@ func TestBadDials(t *testing.T) { h.Set("Sec-WebSocket-Accept", websocket.SecWebSocketAccept(r.Header.Get("Sec-WebSocket-Key"))) return &http.Response{ + ProtoMajor: 1, StatusCode: http.StatusSwitchingProtocols, Header: h, Body: io.NopCloser(strings.NewReader("hi")), @@ -158,6 +160,7 @@ func Test_verifyHostOverride(t *testing.T) { h.Set("Sec-WebSocket-Accept", websocket.SecWebSocketAccept(r.Header.Get("Sec-WebSocket-Key"))) return &http.Response{ + ProtoMajor: 1, StatusCode: http.StatusSwitchingProtocols, Header: h, Body: mockBody{bytes.NewBufferString("hi")}, @@ -317,7 +320,8 @@ func TestDialRedirect(t *testing.T) { _, _, err := websocket.Dial(ctx, "ws://example.com", &websocket.DialOptions{ HTTPClient: mockHTTPClient(func(r *http.Request) (*http.Response, error) { resp := &http.Response{ - Header: http.Header{}, + ProtoMajor: 1, + Header: http.Header{}, } if r.URL.Scheme != "https" { resp.Header.Set("Location", "wss://example.com") From 887c8b32523706a3dc81734dccb24a086dd4e5b3 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 29 Oct 2026 23:18:16 +0000 Subject: [PATCH 07/19] test(dial): cover the HTTP/1.x check in verifyServerResponseH1 Without the check, an HTTP/3 response goes on to the Upgrade header checks, so Dial could treat it as an HTTP/1.1 upgrade. --- dial_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/dial_test.go b/dial_test.go index bf32ce3b..fa84321d 100644 --- a/dial_test.go +++ b/dial_test.go @@ -98,6 +98,25 @@ func TestBadDials(t *testing.T) { assert.Contains(t, err, "failed to WebSocket dial: expected handshake response status code 101 but got 0") }) + t.Run("badProto", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + _, _, err := websocket.Dial(ctx, "ws://example.com", &websocket.DialOptions{ + HTTPClient: mockHTTPClient(func(*http.Request) (*http.Response, error) { + return &http.Response{ + Proto: "HTTP/3.0", + ProtoMajor: 3, + StatusCode: http.StatusSwitchingProtocols, + Body: io.NopCloser(strings.NewReader("hi")), + }, nil + }), + }) + assert.Contains(t, err, "failed to WebSocket dial: expected HTTP/1.x response but got: HTTP/3.0") + }) + t.Run("badBody", func(t *testing.T) { t.Parallel() From 3186028ea8555f90fc3e9acf30e2e9469d29af53 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 29 Oct 2026 23:19:02 +0000 Subject: [PATCH 08/19] refactor(internal/examples/http2): use DialTLSContext http2.Transport.DialTLS is deprecated, and staticcheck fails lint on it. DialTLSContext also lets the transport cancel a dial when the request's context ends. --- internal/examples/http2/client.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/examples/http2/client.go b/internal/examples/http2/client.go index e3e74311..0df4d7d5 100644 --- a/internal/examples/http2/client.go +++ b/internal/examples/http2/client.go @@ -60,8 +60,9 @@ Examples: // Cleartext HTTP/2 (h2c). h2t := &http2.Transport{ AllowHTTP: true, - DialTLS: func(network, addr string, _ *tls.Config) (net.Conn, error) { - return net.Dial(network, addr) + DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, addr) }, } hc = &http.Client{Transport: h2t} From 7cad4a2dbecdf60ef65843aaf40693eaeaccc26a Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 29 Oct 2026 23:19:17 +0000 Subject: [PATCH 09/19] chore(internal/examples/http2): apply gofmt and prettier --- internal/examples/http2/README.md | 2 +- internal/examples/http2/client.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/examples/http2/README.md b/internal/examples/http2/README.md index 5dd845db..2edc27f0 100644 --- a/internal/examples/http2/README.md +++ b/internal/examples/http2/README.md @@ -46,4 +46,4 @@ The client is in `client.go`. It dials the server over HTTP/2 (both `ws://` h2c and `wss://` TLS), sends a single text message, and prints the echoed response. `main.go` wires a small CLI with `server` and `client` subcommands so you can -run and try the example quickly. \ No newline at end of file +run and try the example quickly. diff --git a/internal/examples/http2/client.go b/internal/examples/http2/client.go index 0df4d7d5..84ed8ad4 100644 --- a/internal/examples/http2/client.go +++ b/internal/examples/http2/client.go @@ -81,7 +81,7 @@ Examples: defer cancel() conn, resp, err := websocket.Dial(ctx, rawURL, &websocket.DialOptions{ - HTTPClient: hc, + HTTPClient: hc, HTTPProtocol: websocket.HTTPProtocol2, }) if err != nil { From 3f3a021aa561ed382fb5a8c077c5ea3854284754 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 29 Oct 2026 23:21:10 +0000 Subject: [PATCH 10/19] test(internal/thirdparty/http2): use DialTLSContext http2.Transport.DialTLS is deprecated, and staticcheck fails lint on it. --- internal/thirdparty/http2/http2_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/thirdparty/http2/http2_test.go b/internal/thirdparty/http2/http2_test.go index d02851f8..6dd7d054 100644 --- a/internal/thirdparty/http2/http2_test.go +++ b/internal/thirdparty/http2/http2_test.go @@ -124,8 +124,9 @@ func newH2TLSClientCustomRoundTripper(pre func(*http.Request), post func(*http.R func newH2CClient() *http.Client { h2t := &http2.Transport{ AllowHTTP: true, - DialTLS: func(network, addr string, _ *tls.Config) (net.Conn, error) { - return net.Dial(network, addr) + DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, addr) }, } return &http.Client{Transport: h2t} From d499f15480b390d4021e663bbdf5b9a638e7a4af Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 29 Oct 2026 23:45:18 +0000 Subject: [PATCH 11/19] fix(dial): accept an unset ProtoMajor as HTTP/1.x Custom RoundTrippers that build the 101 response by hand may leave ProtoMajor at 0. Rejecting those would break dials that work today, while any other version except 1 is still rejected. --- dial.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dial.go b/dial.go index 55256613..5090f70d 100644 --- a/dial.go +++ b/dial.go @@ -328,7 +328,9 @@ func verifyServerResponse(opts *DialOptions, copts *compressionOptions, secWebSo } func verifyServerResponseH1(opts *DialOptions, copts *compressionOptions, secWebSocketKey string, resp *http.Response) (*compressionOptions, error) { - if resp.ProtoMajor != 1 { + // Responses built by hand in a custom RoundTripper may leave ProtoMajor + // unset, so 0 is accepted as HTTP/1.x. + if resp.ProtoMajor != 0 && resp.ProtoMajor != 1 { return nil, fmt.Errorf("expected HTTP/1.x response but got: %s", resp.Proto) } From 425ae7136ffef1ad4ca810edb635175ccc204f7b Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 29 Oct 2026 23:45:32 +0000 Subject: [PATCH 12/19] test(dial): keep hand-built responses without ProtoMajor This reverts 3866540. With an unset ProtoMajor accepted, these tests again cover the hand-built responses that custom RoundTrippers return. --- dial_test.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/dial_test.go b/dial_test.go index fa84321d..fdaeb8fa 100644 --- a/dial_test.go +++ b/dial_test.go @@ -90,8 +90,7 @@ func TestBadDials(t *testing.T) { _, _, err := websocket.Dial(ctx, "ws://example.com", &websocket.DialOptions{ HTTPClient: mockHTTPClient(func(*http.Request) (*http.Response, error) { return &http.Response{ - ProtoMajor: 1, - Body: io.NopCloser(strings.NewReader("hi")), + Body: io.NopCloser(strings.NewReader("hi")), }, nil }), }) @@ -130,7 +129,6 @@ func TestBadDials(t *testing.T) { h.Set("Sec-WebSocket-Accept", websocket.SecWebSocketAccept(r.Header.Get("Sec-WebSocket-Key"))) return &http.Response{ - ProtoMajor: 1, StatusCode: http.StatusSwitchingProtocols, Header: h, Body: io.NopCloser(strings.NewReader("hi")), @@ -179,7 +177,6 @@ func Test_verifyHostOverride(t *testing.T) { h.Set("Sec-WebSocket-Accept", websocket.SecWebSocketAccept(r.Header.Get("Sec-WebSocket-Key"))) return &http.Response{ - ProtoMajor: 1, StatusCode: http.StatusSwitchingProtocols, Header: h, Body: mockBody{bytes.NewBufferString("hi")}, @@ -339,8 +336,7 @@ func TestDialRedirect(t *testing.T) { _, _, err := websocket.Dial(ctx, "ws://example.com", &websocket.DialOptions{ HTTPClient: mockHTTPClient(func(r *http.Request) (*http.Response, error) { resp := &http.Response{ - ProtoMajor: 1, - Header: http.Header{}, + Header: http.Header{}, } if r.URL.Scheme != "https" { resp.Header.Set("Location", "wss://example.com") From d2b7a8d6578a7f789c4a61fcdc18c33e7704cad2 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 29 Oct 2026 23:46:33 +0000 Subject: [PATCH 13/19] build(internal): bump golang.org/x/net and golang.org/x/text govulncheck reports 8 vulnerabilities in x/net v0.44.0 and x/text v0.29.0 that the HTTP/2 example calls. internal/thirdparty uses the same x/net for the HTTP/2 tests and moves with it. Every x/net release with the fixes requires go 1.26.0, so both go directives change from 1.26 to 1.26.0. --- internal/examples/go.mod | 6 +++--- internal/examples/go.sum | 8 ++++---- internal/thirdparty/go.mod | 10 +++++----- internal/thirdparty/go.sum | 16 ++++++++-------- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/internal/examples/go.mod b/internal/examples/go.mod index 0da3d2ea..61da4ba5 100644 --- a/internal/examples/go.mod +++ b/internal/examples/go.mod @@ -1,13 +1,13 @@ module github.com/coder/websocket/examples -go 1.26 +go 1.26.0 replace github.com/coder/websocket => ../.. require ( github.com/coder/websocket v0.0.0-00010101000000-000000000000 - golang.org/x/net v0.44.0 + golang.org/x/net v0.62.0 golang.org/x/time v0.13.0 ) -require golang.org/x/text v0.29.0 // indirect +require golang.org/x/text v0.43.0 // indirect diff --git a/internal/examples/go.sum b/internal/examples/go.sum index c8351e3b..78db3b3e 100644 --- a/internal/examples/go.sum +++ b/internal/examples/go.sum @@ -1,6 +1,6 @@ -golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= -golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI= golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= diff --git a/internal/thirdparty/go.mod b/internal/thirdparty/go.mod index cfed2e10..37f334e4 100644 --- a/internal/thirdparty/go.mod +++ b/internal/thirdparty/go.mod @@ -1,6 +1,6 @@ module github.com/coder/websocket/internal/thirdparty -go 1.26 +go 1.26.0 replace github.com/coder/websocket => ../.. @@ -10,7 +10,7 @@ require ( github.com/gobwas/ws v1.4.0 github.com/gorilla/websocket v1.5.3 github.com/lesismal/nbio v1.6.7 - golang.org/x/net v0.44.0 + golang.org/x/net v0.62.0 ) require ( @@ -36,9 +36,9 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.42.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/text v0.43.0 // indirect google.golang.org/protobuf v1.36.9 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/thirdparty/go.sum b/internal/thirdparty/go.sum index 9244c429..17826338 100644 --- a/internal/thirdparty/go.sum +++ b/internal/thirdparty/go.sum @@ -71,22 +71,22 @@ github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2W golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20210513122933-cd7d49e622d5/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= -golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= -golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= -golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= From 9d5a13121db2518bc8fa9643f34720d93dc23b55 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 30 Oct 2026 11:52:28 +0000 Subject: [PATCH 14/19] refactor(internal): serve HTTP/2 with net/http in example and tests x/net deprecates http2.ConfigureServer and h2c. Since Go 1.24, net/http serves HTTP/2 extended CONNECT and cleartext HTTP/2 itself, and it reads GODEBUG=http2xconnect the same way. The tests now run Accept behind the server most users run. --- internal/examples/http2/server.go | 13 ++++--------- internal/thirdparty/http2/http2_test.go | 7 ++++--- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/internal/examples/http2/server.go b/internal/examples/http2/server.go index f419f098..abdd5f9d 100644 --- a/internal/examples/http2/server.go +++ b/internal/examples/http2/server.go @@ -9,9 +9,6 @@ import ( "os" "strings" - "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" - "github.com/coder/websocket" ) @@ -91,17 +88,15 @@ Examples: return errors.New("-cert and -key are required when using -tls") } - // Enable HTTP/2 over TLS. - if err := http2.ConfigureServer(srv, &http2.Server{}); err != nil { - return err - } - + // net/http serves HTTP/2 over TLS by default. fmt.Printf("listening on wss://%s\n", visibleAddr(*addr)) return srv.ListenAndServeTLS(*certFile, *keyFile) } // Cleartext HTTP/2 (h2c). - srv.Handler = h2c.NewHandler(srv.Handler, &http2.Server{}) + srv.Protocols = new(http.Protocols) + srv.Protocols.SetHTTP1(true) + srv.Protocols.SetUnencryptedHTTP2(true) fmt.Printf("listening on ws://%s (h2c)\n", visibleAddr(*addr)) return srv.ListenAndServe() } diff --git a/internal/thirdparty/http2/http2_test.go b/internal/thirdparty/http2/http2_test.go index 6dd7d054..813a5f20 100644 --- a/internal/thirdparty/http2/http2_test.go +++ b/internal/thirdparty/http2/http2_test.go @@ -16,7 +16,6 @@ import ( "time" "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" "github.com/coder/websocket" "github.com/coder/websocket/internal/test/assert" @@ -177,12 +176,14 @@ func runTable(t *testing.T, cases []runTableTestCase) { case "wss": srv := httptest.NewUnstartedServer(echoHandler) srv.EnableHTTP2 = true - _ = http2.ConfigureServer(srv.Config, &http2.Server{}) srv.StartTLS() defer srv.Close() srvURL = strings.Replace(srv.URL, "https://", "wss://", 1) case "ws": - srv := httptest.NewUnstartedServer(h2c.NewHandler(echoHandler, &http2.Server{})) + srv := httptest.NewUnstartedServer(echoHandler) + srv.Config.Protocols = new(http.Protocols) + srv.Config.Protocols.SetHTTP1(true) + srv.Config.Protocols.SetUnencryptedHTTP2(true) srv.Start() defer srv.Close() srvURL = strings.Replace(srv.URL, "http://", "ws://", 1) From f8d55ced1a80c475fcc2a925afb54efc23ef8d47 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 30 Oct 2026 11:52:46 +0000 Subject: [PATCH 15/19] chore(internal): ignore SA1019 for the x/net HTTP/2 client x/net deprecates http2.Transport, but Go 1.26's net/http Transport fails the extended CONNECT request with "invalid header field name ":protocol"", so the example client and the tests still need it. --- internal/examples/http2/client.go | 2 ++ internal/thirdparty/http2/http2_test.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/internal/examples/http2/client.go b/internal/examples/http2/client.go index 84ed8ad4..e1e5e971 100644 --- a/internal/examples/http2/client.go +++ b/internal/examples/http2/client.go @@ -1,3 +1,5 @@ +//lint:file-ignore SA1019 Go 1.26's net/http client rejects the :protocol pseudo-header, so the client uses golang.org/x/net/http2. + package main import ( diff --git a/internal/thirdparty/http2/http2_test.go b/internal/thirdparty/http2/http2_test.go index 813a5f20..ceaffe15 100644 --- a/internal/thirdparty/http2/http2_test.go +++ b/internal/thirdparty/http2/http2_test.go @@ -1,3 +1,5 @@ +//lint:file-ignore SA1019 Go 1.26's net/http client rejects the :protocol pseudo-header, so the client uses golang.org/x/net/http2. + package http2 import ( From 5477e50687fe3b681a1f35a64838653904aa7adb Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 30 Oct 2026 15:20:54 +0000 Subject: [PATCH 16/19] docs: use the HTTPProtocol option name 7f2b1c4 renamed the option from Protocol to HTTPProtocol, but doc.go and README.md still named Protocol. --- README.md | 2 +- doc.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c6b76503..b1c249a3 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ go get github.com/coder/websocket - [CloseRead](https://pkg.go.dev/github.com/coder/websocket#Conn.CloseRead) helper for write only connections - Compile to [Wasm](https://pkg.go.dev/github.com/coder/websocket#hdr-Wasm) - Experimental support for HTTP/2 extended CONNECT (RFC 8441) - - Requires opt-in via the [AcceptOptions](https://pkg.go.dev/github.com/coder/websocket#AcceptOptions) and [DialOptions](https://pkg.go.dev/github.com/coder/websocket#DialOptions) Protocol option + - Requires opt-in via the [AcceptOptions](https://pkg.go.dev/github.com/coder/websocket#AcceptOptions) and [DialOptions](https://pkg.go.dev/github.com/coder/websocket#DialOptions) HTTPProtocol option - Clients must provide a `http2.Transport` - Servers must be started with `GODEBUG=http2xconnect=1`, see https://github.com/golang/go/issues/53208 - See the [http2 example](./internal/examples/http2) diff --git a/doc.go b/doc.go index 4397253b..bc9c1502 100644 --- a/doc.go +++ b/doc.go @@ -23,7 +23,7 @@ // The package supports WebSocket over HTTP/2 via the extended CONNECT // protocol (RFC 8441). // -// This functionality is currently opt-in and requires setting the Protocol +// This functionality is currently opt-in and requires setting the HTTPProtocol // option on the AcceptOptions or DialOptions. When not set, HTTP/1.1 is used. // // Server-side extended CONNECT functionality must currently be enabled by From 4ba01a2771fe2669e5091347879764b57d6882d4 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 30 Oct 2026 15:21:23 +0000 Subject: [PATCH 17/19] docs: require -tags http2legacy for HTTP/2 Dial on Go 1.27 On Go 1.27, x/net's http2.Transport passes requests to net/http, which fails the extended CONNECT request with "invalid header field name ":protocol"". Building with -tags http2legacy restores x/net's own client, and the example client then connects. --- README.md | 2 +- internal/examples/http2/README.md | 3 +++ protocol.go | 5 ++++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b1c249a3..058a16ee 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ go get github.com/coder/websocket - Compile to [Wasm](https://pkg.go.dev/github.com/coder/websocket#hdr-Wasm) - Experimental support for HTTP/2 extended CONNECT (RFC 8441) - Requires opt-in via the [AcceptOptions](https://pkg.go.dev/github.com/coder/websocket#AcceptOptions) and [DialOptions](https://pkg.go.dev/github.com/coder/websocket#DialOptions) HTTPProtocol option - - Clients must provide a `http2.Transport` + - Clients must provide a `http2.Transport`, and on Go 1.27 be built with `-tags http2legacy` - Servers must be started with `GODEBUG=http2xconnect=1`, see https://github.com/golang/go/issues/53208 - See the [http2 example](./internal/examples/http2) diff --git a/internal/examples/http2/README.md b/internal/examples/http2/README.md index 2edc27f0..2bdc0b87 100644 --- a/internal/examples/http2/README.md +++ b/internal/examples/http2/README.md @@ -10,6 +10,9 @@ It supports: ## Run +On Go 1.27, the client needs `-tags http2legacy`, for example +`go run -tags http2legacy . client ws://127.0.0.1:8080`. The server does not. + Cleartext HTTP/2: ```console diff --git a/protocol.go b/protocol.go index 81a4b17a..a212d0d0 100644 --- a/protocol.go +++ b/protocol.go @@ -21,7 +21,10 @@ import "fmt" // - HTTPProtocolAny: accept either HTTP/1.1 Upgrade or HTTP/2 extended CONNECT. // // For HTTP/2 client dialing, callers must supply an http.Client configured with -// an http2.Transport (from golang.org/x/net/http2). +// an http2.Transport (from golang.org/x/net/http2). On Go 1.27, build with +// -tags http2legacy: without it, http2.Transport hands requests to net/http, +// which rejects the :protocol pseudo-header that extended CONNECT needs (see +// https://github.com/golang/go/issues/53208). // // Experimental: This type is experimental and may change in the future. type HTTPProtocol int From d2f16adc7a79c18cb99aaddc0f9fb3d79ca3d25e Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 30 Oct 2026 17:52:23 +0000 Subject: [PATCH 18/19] fix(dial): check Sec-WebSocket-Accept over HTTP/2 only when present RFC 8441 section 5 drops Sec-WebSocket-Accept, and Kestrel, Jetty, axum, libwebsockets and nghttpx send none, so Dial failed against them. An accept that is present still has to match our key. The key is still sent, because proxies such as Envoy forward it to HTTP/1.1 servers. --- dial.go | 8 +++++-- internal/thirdparty/http2/http2_test.go | 29 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/dial.go b/dial.go index 5090f70d..abec542c 100644 --- a/dial.go +++ b/dial.go @@ -286,6 +286,8 @@ func handshakeRequestH2(ctx context.Context, u *url.URL, opts *DialOptions, copt // RFC 8441 protocol header. req.Header.Set(":protocol", "websocket") req.Header.Set("Sec-WebSocket-Version", "13") + // RFC 8441 §5 does not use Sec-WebSocket-Key, but proxies such as Envoy + // forward this request to HTTP/1.1 servers, which require one. req.Header.Set("Sec-WebSocket-Key", secWebSocketKey) if len(opts.Subprotocols) > 0 { req.Header.Set("Sec-WebSocket-Protocol", strings.Join(opts.Subprotocols, ",")) @@ -371,9 +373,11 @@ func verifyServerResponseH2(opts *DialOptions, copts *compressionOptions, secWeb return nil, fmt.Errorf("expected 2xx status code for extended CONNECT but got: %d", resp.StatusCode) } - if resp.Header.Get("Sec-WebSocket-Accept") != secWebSocketAccept(secWebSocketKey) { + // Most HTTP/2 servers send no Sec-WebSocket-Accept (RFC 8441 §5). One + // that is present answers our key, so it must match. + if accept := resp.Header.Get("Sec-WebSocket-Accept"); accept != "" && accept != secWebSocketAccept(secWebSocketKey) { return nil, fmt.Errorf("WebSocket protocol violation: invalid Sec-WebSocket-Accept %q, key %q", - resp.Header.Get("Sec-WebSocket-Accept"), + accept, secWebSocketKey, ) } diff --git a/internal/thirdparty/http2/http2_test.go b/internal/thirdparty/http2/http2_test.go index ceaffe15..a0e9a770 100644 --- a/internal/thirdparty/http2/http2_test.go +++ b/internal/thirdparty/http2/http2_test.go @@ -155,6 +155,7 @@ type runTableTestCase struct { wantProto int // 1 or 2 wantStatus int // Wanted status code (e.g., 200 or 100). wantErr bool // Want a Dial error. + wantErrMsg string } // runTable executes a table of cases under the current GODEBUG mode. @@ -212,6 +213,9 @@ func runTable(t *testing.T, cases []runTableTestCase) { if err == nil { t.Fatal("expected error, got nil") } + if tc.wantErrMsg != "" { + assert.Contains(t, err, tc.wantErrMsg) + } return } assert.Success(t, err) @@ -291,6 +295,31 @@ func TestHTTP2Suite_XCONNECT_Enabled(t *testing.T) { serverProto: websocket.HTTPProtocolAny, wantProto: 2, }, + { + name: "OK server without Sec-WebSocket-Accept", + scheme: "wss", + client: func(t *testing.T) *http.Client { + return newH2TLSClientCustomRoundTripper(nil, func(resp *http.Response) { + resp.Header.Del("Sec-WebSocket-Accept") + }) + }, + clientProto: websocket.HTTPProtocol2, + serverProto: websocket.HTTPProtocol2, + wantProto: 2, + }, + { + name: "Error dial wrong Sec-WebSocket-Accept", + scheme: "wss", + client: func(t *testing.T) *http.Client { + return newH2TLSClientCustomRoundTripper(nil, func(resp *http.Response) { + resp.Header.Set("Sec-WebSocket-Accept", "wrong") + }) + }, + clientProto: websocket.HTTPProtocol2, + serverProto: websocket.HTTPProtocol2, + wantErr: true, + wantErrMsg: `invalid Sec-WebSocket-Accept "wrong"`, + }, { name: "Error server missing :protocol header", scheme: "wss", From 818ed69f68227816241000d1bc93ea2b14f59428 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 30 Oct 2026 17:53:12 +0000 Subject: [PATCH 19/19] fix(accept): stop requiring Sec-WebSocket-Key over HTTP/2 Chrome and Firefox send no Sec-WebSocket-Key on an HTTP/2 extended CONNECT, as RFC 8441 section 5 specifies, so Accept rejected them with 400. A key that is present is still answered with Sec-WebSocket-Accept, because proxies such as Envoy forward an HTTP/1.1 client's key without generating the accept that client checks. --- accept.go | 29 +++++++++++++++++------- internal/thirdparty/http2/http2_test.go | 30 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/accept.go b/accept.go index 2098e4cd..e25abbb3 100644 --- a/accept.go +++ b/accept.go @@ -150,7 +150,9 @@ func accept(w http.ResponseWriter, r *http.Request, opts *AcceptOptions) (_ *Con switch proto { case HTTPProtocol2: // Prepare response headers for H2 (no Connection/Upgrade). - w.Header().Set("Sec-WebSocket-Accept", secWebSocketAccept(key)) + if key != "" { + w.Header().Set("Sec-WebSocket-Accept", secWebSocketAccept(key)) + } subproto := selectSubprotocol(r, opts.Subprotocols) if subproto != "" { @@ -316,22 +318,33 @@ func verifyClientRequestH2(w http.ResponseWriter, r *http.Request) (key string, return "", http.StatusMethodNotAllowed, fmt.Errorf("WebSocket protocol violation: handshake request method is not CONNECT but %q", r.Method) } - key, errCode, err := validateSecWebSocketHeaders(w, r) + errCode, err := validateSecWebSocketVersion(w, r) if err != nil { return "", errCode, err } - return key, 0, nil + // RFC 8441 §5 replaces Sec-WebSocket-Key with :protocol, and Chrome and + // Firefox send none. A key that does arrive is returned so Accept answers + // it: proxies such as Envoy forward an HTTP/1.1 client's key without + // generating the Sec-WebSocket-Accept that client checks. + return strings.TrimSpace(r.Header.Get("Sec-WebSocket-Key")), 0, nil +} + +// validateSecWebSocketVersion sets Sec-WebSocket-Version: 13 on a mismatch. +func validateSecWebSocketVersion(w http.ResponseWriter, r *http.Request) (errCode int, _ error) { + if r.Header.Get("Sec-WebSocket-Version") != "13" { + w.Header().Set("Sec-WebSocket-Version", "13") + return http.StatusBadRequest, fmt.Errorf("unsupported WebSocket protocol version (only 13 is supported): %q", r.Header.Get("Sec-WebSocket-Version")) + } + return 0, nil } // validateSecWebSocketHeaders validates Sec-WebSocket-Version/Sec-WebSocket-Key // and returns the trimmed key. -// -// It sets Sec-WebSocket-Version: 13 on version mismatch. func validateSecWebSocketHeaders(w http.ResponseWriter, r *http.Request) (key string, errCode int, _ error) { - if r.Header.Get("Sec-WebSocket-Version") != "13" { - w.Header().Set("Sec-WebSocket-Version", "13") - return "", http.StatusBadRequest, fmt.Errorf("unsupported WebSocket protocol version (only 13 is supported): %q", r.Header.Get("Sec-WebSocket-Version")) + errCode, err := validateSecWebSocketVersion(w, r) + if err != nil { + return "", errCode, err } websocketSecKeys := r.Header.Values("Sec-WebSocket-Key") diff --git a/internal/thirdparty/http2/http2_test.go b/internal/thirdparty/http2/http2_test.go index a0e9a770..fb34ccbc 100644 --- a/internal/thirdparty/http2/http2_test.go +++ b/internal/thirdparty/http2/http2_test.go @@ -295,6 +295,36 @@ func TestHTTP2Suite_XCONNECT_Enabled(t *testing.T) { serverProto: websocket.HTTPProtocolAny, wantProto: 2, }, + { + name: "OK client without Sec-WebSocket-Key", + scheme: "wss", + client: func(t *testing.T) *http.Client { + return newH2TLSClientCustomRoundTripper(func(req *http.Request) { + req.Header.Del("Sec-WebSocket-Key") + }, func(resp *http.Response) { + if v := resp.Header.Get("Sec-WebSocket-Accept"); v != "" { + t.Errorf("Sec-WebSocket-Accept sent without a key: %q", v) + } + }) + }, + clientProto: websocket.HTTPProtocol2, + serverProto: websocket.HTTPProtocol2, + wantProto: 2, + }, + { + name: "OK server answers Sec-WebSocket-Key", + scheme: "wss", + client: func(t *testing.T) *http.Client { + return newH2TLSClientCustomRoundTripper(nil, func(resp *http.Response) { + if resp.Header.Get("Sec-WebSocket-Accept") == "" { + t.Error("no Sec-WebSocket-Accept for the request's key") + } + }) + }, + clientProto: websocket.HTTPProtocol2, + serverProto: websocket.HTTPProtocol2, + wantProto: 2, + }, { name: "OK server without Sec-WebSocket-Accept", scheme: "wss",