diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 836381ef..2dd5ce20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,10 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version-file: ./go.mod + # HACK(mafredri): The exampels and thirdparty library require Go 1.24 + # due to `golang.org/x/crypto` import, so lint tools must be built by + # the highest version of Go used. + go-version-file: ./internal/thirdparty/go.mod - run: make fmt lint: @@ -27,7 +30,10 @@ jobs: - run: go version - uses: actions/setup-go@v5 with: - go-version-file: ./go.mod + # HACK(mafredri): The exampels and thirdparty library require Go 1.24 + # due to `golang.org/x/crypto` import, so lint tools must be built by + # the highest version of Go used. + go-version-file: ./internal/thirdparty/go.mod - run: make lint test: diff --git a/.github/workflows/daily.yml b/.github/workflows/daily.yml index 62e3d337..01c08f72 100644 --- a/.github/workflows/daily.yml +++ b/.github/workflows/daily.yml @@ -34,33 +34,3 @@ jobs: with: name: coverage.html path: ./ci/out/coverage.html - bench-dev: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: dev - - uses: actions/setup-go@v5 - with: - go-version-file: ./go.mod - - run: AUTOBAHN=1 make bench - test-dev: - runs-on: ubuntu-latest - steps: - - name: Disable AppArmor - if: runner.os == 'Linux' - run: | - # Disable AppArmor for Ubuntu 23.10+. - # https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md - echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns - - uses: actions/checkout@v4 - with: - ref: dev - - uses: actions/setup-go@v5 - with: - go-version-file: ./go.mod - - run: AUTOBAHN=1 make test - - uses: actions/upload-artifact@v4 - with: - name: coverage-dev.html - path: ./ci/out/coverage.html diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index a78ce1b9..b742bbfd 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -38,11 +38,7 @@ jobs: go-version-file: ./go.mod - name: Generate coverage and badge run: | - make test - mkdir -p ./ci/out/static - cp ./ci/out/coverage.html ./ci/out/static/coverage.html - percent=$(go tool cover -func ./ci/out/coverage.prof | tail -n1 | awk '{print $3}' | tr -d '%') - wget -O ./ci/out/static/coverage.svg "https://img.shields.io/badge/coverage-${percent}%25-success" + make static - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: diff --git a/Makefile b/Makefile index a3e4a20d..49eec520 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,11 @@ lint: test: ./ci/test.sh +.PHONY: static +static: + make test + ./ci/static.sh + .PHONY: bench bench: - ./ci/bench.sh \ No newline at end of file + ./ci/bench.sh diff --git a/README.md b/README.md index 6e986897..fb14ec4f 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/nhooyr/websocket/issues/209) - [ ] Assembly for WebSocket masking [#16](https://github.com/nhooyr/websocket/issues/16) - WIP at [#326](https://github.com/nhooyr/websocket/pull/326), about 3x faster -- [ ] HTTP/2 [#4](https://github.com/nhooyr/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/nhooyr/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 cc990428..c2d55891 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/nhooyr/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/lint.sh b/ci/lint.sh index 316b035d..1e8accf8 100755 --- a/ci/lint.sh +++ b/ci/lint.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/usr/bin/env bash set -eu cd -- "$(dirname "$0")/.." diff --git a/ci/static.sh b/ci/static.sh new file mode 100755 index 00000000..68d4b6e7 --- /dev/null +++ b/ci/static.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -eu + +cd -- "$(dirname "$0")/.." + +if [[ ! -f ./ci/out/profile.txt ]]; then + echo "No coverage profile found, run 'make test' first" + exit 1 +fi + +if [[ ! -f ./ci/out/coverage.html ]]; then + echo "No coverage report found, run 'make test' first" + exit 1 +fi + +rm -rf ./ci/out/static +mkdir -p ./ci/out/static +cp ./ci/out/coverage.html ./ci/out/static/coverage.html +percent=$(go tool cover -func ./ci/out/profile.txt | tail -n1 | awk '{print $3}' | tr -d '%') +wget -O ./ci/out/static/coverage.svg "https://img.shields.io/badge/coverage-${percent}%25-success" diff --git a/ci/test.sh b/ci/test.sh index cc3c22d7..40cef438 100755 --- a/ci/test.sh +++ b/ci/test.sh @@ -1,36 +1,73 @@ -#!/bin/sh +#!/usr/bin/env bash set -eu + cd -- "$(dirname "$0")/.." -( - cd ./internal/examples - go test "$@" ./... -) -( - cd ./internal/thirdparty - go test "$@" ./... -) - -( - GOARCH=arm64 go test -c -o ./ci/out/websocket-arm64.test "$@" . - if [ "$#" -eq 0 ]; then - if [ "${CI-}" ]; then - sudo apt-get update - sudo apt-get install -y qemu-user-static - ln -s /usr/bin/qemu-aarch64-static /usr/local/bin/qemu-aarch64 - fi - qemu-aarch64 ./ci/out/websocket-arm64.test -test.run=TestMask - fi -) +go install github.com/agnivade/wasmbrowsertest@8be019f6c6dceae821467b4c589eb195c2b761ce +echo "+++ Testing websocket library and generating coverage" +# HTTP/2 tests are kept in a separate module currently. +echo "++++ Building HTTP/2 tests" +rm -f ci/out/http2.test +pushd internal/thirdparty +go test -c -cover -covermode=atomic -coverpkg=github.com/coder/websocket -o ../../ci/out/http2.test ./http2 +popd -go install github.com/agnivade/wasmbrowsertest@8be019f6c6dceae821467b4c589eb195c2b761ce -go test --race --bench=. --timeout=1h --covermode=atomic --coverprofile=ci/out/coverage.prof --coverpkg=./... "$@" ./... -sed -i.bak '/stringer\.go/d' ci/out/coverage.prof -sed -i.bak '/nhooyr.io\/websocket\/internal\/test/d' ci/out/coverage.prof -sed -i.bak '/examples/d' ci/out/coverage.prof +# Run main tests and generate coverage data. +rm -rf ci/out/profile.txt ci/out/profile +coverbase="$(pwd)/ci/out/profile" +coverdirs=("$coverbase"/{xconnect=0,xconnect=1,race=0,race=1}) +mkdir -p "${coverdirs[@]}" + +# Generate test coverage for http2 codepaths. +echo "++++ Running HTTP/2 tests: http2xconnect=0" +GODEBUG=http2xconnect=0 ./ci/out/http2.test -test.timeout=30s -test.gocoverdir="$coverbase/xconnect=0" -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="$coverbase/xconnect=1" -test.run='.*XCONNECT_Enabled$' -test.count=1 + +coverpkgs=($(go list ./... | grep -v websocket/internal/test)) +coverpkgsjoined=$(IFS=,; echo "${coverpkgs[*]}") + +echo "++++ Running main tests" +go test --bench=. --timeout=1h -cover -covermode=atomic -coverpkg="$coverpkgsjoined" -test.gocoverdir="$coverbase/race=0" "$@" ./... + +echo "++++ Running main tests (race)" +# Disable autobahn for race tests. +AUTOBAHN= go test -race -timeout=1h -cover -covermode=atomic -coverpkg="$coverpkgsjoined" -test.gocoverdir="$coverbase/race=1" "$@" ./... + +echo "+++ Testing examples" +pushd internal/examples +go test "$@" ./... +popd + +echo "+++ Testing thirdparty" +pushd internal/thirdparty +go test "$@" $(go list ./... | grep -v ./http2) +popd + +if [[ $# -eq 0 ]]; then + echo "+++ Running TestMask for arm64" + + run_test=(go test .) + if [[ $(go env GOARCH) != "arm64" ]]; then + if [ "${CI-}" ]; then + sudo apt-get update + sudo apt-get install -y qemu-user-static + ln -s /usr/bin/qemu-aarch64-static /usr/local/bin/qemu-aarch64 + fi + GOARCH=arm64 go test -c -o ./ci/out/websocket-arm64.test "$@" . + run_test=(qemu-aarch64 ./ci/out/websocket-arm64.test) + fi + "${run_test[@]}" -test.run=TestMask +fi -# Last line is the total coverage. -go tool cover -func ci/out/coverage.prof | tail -n1 +echo "++++ Generating test coverage data" +mkdir -p "$coverbase/merged" +coverdirsjoined=$(IFS=,; echo "${coverdirs[*]}") +go tool covdata merge -i="$coverdirsjoined" -o "${coverbase}/merged" +go tool covdata textfmt -i="${coverbase}/merged" -o ci/out/profile.txt +go tool cover -func ci/out/profile.txt | tail -n1 # Last line is the total coverage. +go tool cover -html=ci/out/profile.txt -o=ci/out/coverage.html -go tool cover -html=ci/out/coverage.prof -o=ci/out/coverage.html +echo "+++ Done" +exit 0 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/go.mod b/go.mod index d32fbd77..8f8c7043 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/coder/websocket -go 1.23 +go 1.23.12 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 e368b76b..d4abdedb 100644 --- a/internal/examples/go.mod +++ b/internal/examples/go.mod @@ -1,10 +1,13 @@ module github.com/coder/websocket/examples -go 1.23 +go 1.24.7 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 7a86aca9..7269976f 100644 --- a/internal/thirdparty/go.mod +++ b/internal/thirdparty/go.mod @@ -1,45 +1,44 @@ module github.com/coder/websocket/internal/thirdparty -go 1.23 +go 1.24.7 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.