├── .github ├── FUNDING.yml ├── actions │ └── go-check-setup │ │ └── action.yml ├── dependabot.yml ├── uci.yml └── workflows │ ├── go-check-config.json │ ├── go-check.yml │ ├── go-test.yml │ ├── interop.yml │ ├── release-check.yml │ ├── releaser.yml │ └── tagpush.yml ├── .gitignore ├── LICENSE ├── README.md ├── client.go ├── client_test.go ├── codecov.yml ├── errors.go ├── errors_test.go ├── go.mod ├── go.sum ├── interop ├── index.html ├── interop.py ├── main.go └── requirements.txt ├── mock_connection_test.go ├── mock_stream_test.go ├── protocol.go ├── server.go ├── server_test.go ├── session.go ├── session_manager.go ├── session_test.go ├── stream.go ├── streams_map.go ├── tls_utils_test.go ├── version.json └── webtransport_test.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [marten-seemann] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/actions/go-check-setup/action.yml: -------------------------------------------------------------------------------- 1 | runs: 2 | using: "composite" 3 | steps: 4 | - name: Install mockgen 5 | shell: bash 6 | run: go install github.com/golang/mock/mockgen@latest 7 | - name: Install goimports 8 | shell: bash 9 | run: go install golang.org/x/tools/cmd/goimports@latest 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/uci.yml: -------------------------------------------------------------------------------- 1 | # Welcome to Unified CI! This file contains the configuration for your project. 2 | # You can edit it to customize the behavior of Unified CI. 3 | # For more information, see https://github.com/ipdxco/unified-github-workflows 4 | 5 | # The Unified CI templates which should be distributed to your project. 6 | files: [".github/workflows/go-check.yml",".github/workflows/go-test.yml",".github/workflows/release-check.yml",".github/workflows/releaser.yml",".github/workflows/tagpush.yml"] 7 | -------------------------------------------------------------------------------- /.github/workflows/go-check-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "gogenerate": true 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/go-check.yml: -------------------------------------------------------------------------------- 1 | name: Go Checks 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: ["master"] 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'push' && github.sha || github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | go-check: 18 | uses: ipdxco/unified-github-workflows/.github/workflows/go-check.yml@v1.0 19 | -------------------------------------------------------------------------------- /.github/workflows/go-test.yml: -------------------------------------------------------------------------------- 1 | name: Go Test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: ["master"] 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'push' && github.sha || github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | go-test: 18 | uses: ipdxco/unified-github-workflows/.github/workflows/go-test.yml@v1.0 19 | secrets: 20 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 21 | -------------------------------------------------------------------------------- /.github/workflows/interop.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Interop 3 | 4 | jobs: 5 | interop: 6 | runs-on: "ubuntu-latest" 7 | steps: 8 | - uses: actions/checkout@v4 9 | - uses: nanasess/setup-chromedriver@v2 10 | with: 11 | # Optional: do not specify to match Chrome's version 12 | chromedriver-version: '124.0.6367.62' 13 | - id: go-mod 14 | uses: pl-strflt/uci/.github/actions/read-go-mod@main 15 | - id: go 16 | env: 17 | GO_MOD_VERSION: ${{ fromJSON(steps.go-mod.outputs.json).Go }} 18 | run: | 19 | MAJOR="${GO_MOD_VERSION%.[0-9]*}" 20 | MINOR="${GO_MOD_VERSION#[0-9]*.}" 21 | echo "version=$MAJOR.$(($MINOR+1)).x" | tee -a $GITHUB_OUTPUT 22 | - uses: actions/setup-go@v5 23 | with: 24 | go-version: ${{ steps.go.outputs.version }} 25 | - name: Build interop server 26 | run: go build -o interopserver interop/main.go 27 | - uses: actions/setup-python@v5 28 | with: 29 | python-version: '3.10' 30 | - name: Install Python dependencies 31 | run: pip install -r interop/requirements.txt 32 | - name: Run interop tests 33 | run: | 34 | ./interopserver & 35 | timeout 120 python interop/interop.py 36 | -------------------------------------------------------------------------------- /.github/workflows/release-check.yml: -------------------------------------------------------------------------------- 1 | name: Release Checker 2 | 3 | on: 4 | pull_request_target: 5 | paths: [ 'version.json' ] 6 | types: [ opened, synchronize, reopened, labeled, unlabeled ] 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: write 11 | pull-requests: write 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | release-check: 19 | uses: ipdxco/unified-github-workflows/.github/workflows/release-check.yml@v1.0 20 | -------------------------------------------------------------------------------- /.github/workflows/releaser.yml: -------------------------------------------------------------------------------- 1 | name: Releaser 2 | 3 | on: 4 | push: 5 | paths: [ 'version.json' ] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: write 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.sha }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | releaser: 17 | uses: ipdxco/unified-github-workflows/.github/workflows/releaser.yml@v1.0 18 | -------------------------------------------------------------------------------- /.github/workflows/tagpush.yml: -------------------------------------------------------------------------------- 1 | name: Tag Push Checker 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | permissions: 9 | contents: read 10 | issues: write 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | releaser: 18 | uses: ipdxco/unified-github-workflows/.github/workflows/tagpush.yml@v1.0 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.qlog 2 | *.sqlog 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Marten Seemann 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # webtransport-go 2 | 3 | [![Documentation](https://img.shields.io/badge/docs-quic--go.net-red?style=flat)](https://quic-go.net/docs/) 4 | [![PkgGoDev](https://pkg.go.dev/badge/github.com/quic-go/webtransport-go)](https://pkg.go.dev/github.com/quic-go/webtransport-go) 5 | [![Code Coverage](https://img.shields.io/codecov/c/github/quic-go/webtransport-go/master.svg?style=flat-square)](https://codecov.io/gh/quic-go/webtransport-go/) 6 | 7 | webtransport-go is an implementation of the WebTransport protocol, based on [quic-go](https://github.com/quic-go/quic-go). It currently implements [draft-02](https://www.ietf.org/archive/id/draft-ietf-webtrans-http3-02.html) of the specification. 8 | 9 | Detailed documentation can be found on [quic-go.net](https://quic-go.net/docs/). 10 | 11 | ## webtransport-go is currently unfunded. 12 | 13 | ### What does this mean? 14 | 15 | webtransport-go has been unfunded since the beginning of 2024. For the first half of the year, I have been maintaining the project in my spare time. Maintaining high-quality open-source software requires significant time and effort. This situation is becoming unsustainable, and as of June 2024, I will be ceasing maintenance work on the project. 16 | 17 | Specifically, this means: 18 | * I will no longer respond to issues or review PRs. 19 | * I will not keep the API in sync with quic-go. 20 | * Since WebTransport is still an IETF draft, browser compatibility will break as soon as the interoperability target changes. 21 | 22 | ### If your project relies on WebTransport support, what can you do? 23 | 24 | I’m glad you asked. First, I would like to hear about your use case. Second, please consider sponsoring the maintenance and future development of the project. It’s best to reach out to me via email. 25 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package webtransport 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "net/url" 10 | "sync" 11 | "time" 12 | 13 | "github.com/quic-go/quic-go" 14 | "github.com/quic-go/quic-go/http3" 15 | "github.com/quic-go/quic-go/quicvarint" 16 | ) 17 | 18 | var errNoWebTransport = errors.New("server didn't enable WebTransport") 19 | 20 | type Dialer struct { 21 | // TLSClientConfig is the TLS client config used when dialing the QUIC connection. 22 | // It must set the h3 ALPN. 23 | TLSClientConfig *tls.Config 24 | 25 | // QUICConfig is the QUIC config used when dialing the QUIC connection. 26 | QUICConfig *quic.Config 27 | 28 | // StreamReorderingTime is the time an incoming WebTransport stream that cannot be associated 29 | // with a session is buffered. 30 | // This can happen if the response to a CONNECT request (that creates a new session) is reordered, 31 | // and arrives after the first WebTransport stream(s) for that session. 32 | // Defaults to 5 seconds. 33 | StreamReorderingTimeout time.Duration 34 | 35 | // DialAddr is the function used to dial the underlying QUIC connection. 36 | // If unset, quic.DialAddrEarly will be used. 37 | DialAddr func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) 38 | 39 | ctx context.Context 40 | ctxCancel context.CancelFunc 41 | 42 | initOnce sync.Once 43 | 44 | conns sessionManager 45 | } 46 | 47 | func (d *Dialer) init() { 48 | timeout := d.StreamReorderingTimeout 49 | if timeout == 0 { 50 | timeout = 5 * time.Second 51 | } 52 | d.conns = *newSessionManager(timeout) 53 | d.ctx, d.ctxCancel = context.WithCancel(context.Background()) 54 | } 55 | 56 | func (d *Dialer) Dial(ctx context.Context, urlStr string, reqHdr http.Header) (*http.Response, *Session, error) { 57 | d.initOnce.Do(func() { d.init() }) 58 | 59 | // Technically, this is not true. DATAGRAMs could be sent using the Capsule protocol. 60 | // However, quic-go currently enforces QUIC datagram support if HTTP/3 datagrams are enabled. 61 | quicConf := d.QUICConfig 62 | if quicConf == nil { 63 | quicConf = &quic.Config{EnableDatagrams: true} 64 | } else if !d.QUICConfig.EnableDatagrams { 65 | return nil, nil, errors.New("webtransport: DATAGRAM support required, enable it via QUICConfig.EnableDatagrams") 66 | } 67 | 68 | tlsConf := d.TLSClientConfig 69 | if tlsConf == nil { 70 | tlsConf = &tls.Config{} 71 | } else { 72 | tlsConf = tlsConf.Clone() 73 | } 74 | if len(tlsConf.NextProtos) == 0 { 75 | tlsConf.NextProtos = []string{http3.NextProtoH3} 76 | } 77 | 78 | u, err := url.Parse(urlStr) 79 | if err != nil { 80 | return nil, nil, err 81 | } 82 | if reqHdr == nil { 83 | reqHdr = http.Header{} 84 | } 85 | reqHdr.Set(webTransportDraftOfferHeaderKey, "1") 86 | req := &http.Request{ 87 | Method: http.MethodConnect, 88 | Header: reqHdr, 89 | Proto: "webtransport", 90 | Host: u.Host, 91 | URL: u, 92 | } 93 | req = req.WithContext(ctx) 94 | 95 | dialAddr := d.DialAddr 96 | if dialAddr == nil { 97 | dialAddr = quic.DialAddrEarly 98 | } 99 | qconn, err := dialAddr(ctx, u.Host, tlsConf, quicConf) 100 | if err != nil { 101 | return nil, nil, err 102 | } 103 | tr := &http3.Transport{ 104 | EnableDatagrams: true, 105 | StreamHijacker: func(ft http3.FrameType, connTracingID quic.ConnectionTracingID, str quic.Stream, e error) (hijacked bool, err error) { 106 | if isWebTransportError(e) { 107 | return true, nil 108 | } 109 | if ft != webTransportFrameType { 110 | return false, nil 111 | } 112 | id, err := quicvarint.Read(quicvarint.NewReader(str)) 113 | if err != nil { 114 | if isWebTransportError(err) { 115 | return true, nil 116 | } 117 | return false, err 118 | } 119 | d.conns.AddStream(connTracingID, str, sessionID(id)) 120 | return true, nil 121 | }, 122 | UniStreamHijacker: func(st http3.StreamType, connTracingID quic.ConnectionTracingID, str quic.ReceiveStream, err error) (hijacked bool) { 123 | if st != webTransportUniStreamType && !isWebTransportError(err) { 124 | return false 125 | } 126 | d.conns.AddUniStream(connTracingID, str) 127 | return true 128 | }, 129 | } 130 | 131 | conn := tr.NewClientConn(qconn) 132 | select { 133 | case <-conn.ReceivedSettings(): 134 | case <-d.ctx.Done(): 135 | return nil, nil, context.Cause(d.ctx) 136 | } 137 | settings := conn.Settings() 138 | if !settings.EnableExtendedConnect { 139 | return nil, nil, errors.New("server didn't enable Extended CONNECT") 140 | } 141 | if !settings.EnableDatagrams { 142 | return nil, nil, errors.New("server didn't enable HTTP/3 datagram support") 143 | } 144 | if settings.Other == nil { 145 | return nil, nil, errNoWebTransport 146 | } 147 | s, ok := settings.Other[settingsEnableWebtransport] 148 | if !ok || s != 1 { 149 | return nil, nil, errNoWebTransport 150 | } 151 | 152 | requestStr, err := conn.OpenRequestStream(ctx) // TODO: put this on the Connection (maybe introduce a ClientConnection?) 153 | if err != nil { 154 | return nil, nil, err 155 | } 156 | if err := requestStr.SendRequestHeader(req); err != nil { 157 | return nil, nil, err 158 | } 159 | // TODO(#136): create the session to allow optimistic opening of streams and sending of datagrams 160 | rsp, err := requestStr.ReadResponse() 161 | if err != nil { 162 | return nil, nil, err 163 | } 164 | if rsp.StatusCode < 200 || rsp.StatusCode >= 300 { 165 | return rsp, nil, fmt.Errorf("received status %d", rsp.StatusCode) 166 | } 167 | return rsp, d.conns.AddSession(conn, sessionID(requestStr.StreamID()), requestStr), nil 168 | } 169 | 170 | func (d *Dialer) Close() error { 171 | d.ctxCancel() 172 | return nil 173 | } 174 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package webtransport_test 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net" 10 | "sync/atomic" 11 | "testing" 12 | "time" 13 | 14 | "github.com/quic-go/webtransport-go" 15 | 16 | "github.com/quic-go/quic-go" 17 | "github.com/quic-go/quic-go/http3" 18 | "github.com/quic-go/quic-go/quicvarint" 19 | 20 | "github.com/stretchr/testify/require" 21 | ) 22 | 23 | type delayedStream struct { 24 | done <-chan struct{} 25 | quic.Stream 26 | } 27 | 28 | func (s *delayedStream) Read(b []byte) (int, error) { 29 | <-s.done 30 | return s.Stream.Read(b) 31 | } 32 | 33 | type requestStreamDelayingConn struct { 34 | done <-chan struct{} 35 | counter int32 36 | quic.EarlyConnection 37 | } 38 | 39 | func (c *requestStreamDelayingConn) OpenStreamSync(ctx context.Context) (quic.Stream, error) { 40 | str, err := c.EarlyConnection.OpenStreamSync(ctx) 41 | if err != nil { 42 | return nil, err 43 | } 44 | if atomic.CompareAndSwapInt32(&c.counter, 0, 1) { 45 | return &delayedStream{done: c.done, Stream: str}, nil 46 | } 47 | return str, nil 48 | } 49 | 50 | const ( 51 | // Extended CONNECT, RFC 9220 52 | settingExtendedConnect = 0x8 53 | // HTTP Datagrams, RFC 9297 54 | settingDatagram = 0x33 55 | // WebTransport 56 | settingsEnableWebtransport = 0x2b603742 57 | ) 58 | 59 | // appendSettingsFrame serializes an HTTP/3 SETTINGS frame 60 | // It reimplements the function in the http3 package, in a slightly simplified way. 61 | func appendSettingsFrame(b []byte, values map[uint64]uint64) []byte { 62 | b = quicvarint.Append(b, 0x4) 63 | var l uint64 64 | for k, val := range values { 65 | l += uint64(quicvarint.Len(k)) + uint64(quicvarint.Len(val)) 66 | } 67 | b = quicvarint.Append(b, l) 68 | for id, val := range values { 69 | b = quicvarint.Append(b, id) 70 | b = quicvarint.Append(b, val) 71 | } 72 | return b 73 | } 74 | 75 | func TestClientInvalidResponseHandling(t *testing.T) { 76 | tlsConf := tlsConf.Clone() 77 | tlsConf.NextProtos = []string{"h3"} 78 | s, err := quic.ListenAddr("localhost:0", tlsConf, &quic.Config{EnableDatagrams: true}) 79 | require.NoError(t, err) 80 | errChan := make(chan error) 81 | go func() { 82 | conn, err := s.Accept(context.Background()) 83 | require.NoError(t, err) 84 | // send the SETTINGS frame 85 | settingsStr, err := conn.OpenUniStream() 86 | require.NoError(t, err) 87 | _, err = settingsStr.Write(appendSettingsFrame([]byte{0} /* stream type */, map[uint64]uint64{ 88 | settingDatagram: 1, 89 | settingExtendedConnect: 1, 90 | settingsEnableWebtransport: 1, 91 | })) 92 | require.NoError(t, err) 93 | 94 | str, err := conn.AcceptStream(context.Background()) 95 | require.NoError(t, err) 96 | // write an HTTP/3 data frame. This will cause an error, since a HEADERS frame is expected 97 | var b []byte 98 | b = quicvarint.Append(b, 0x0) 99 | b = quicvarint.Append(b, 1337) 100 | _, err = str.Write(b) 101 | require.NoError(t, err) 102 | for { 103 | if _, err := str.Read(make([]byte, 64)); err != nil { 104 | errChan <- err 105 | return 106 | } 107 | } 108 | }() 109 | 110 | d := webtransport.Dialer{TLSClientConfig: &tls.Config{RootCAs: certPool}} 111 | _, _, err = d.Dial(context.Background(), fmt.Sprintf("https://localhost:%d", s.Addr().(*net.UDPAddr).Port), nil) 112 | require.Error(t, err) 113 | var sErr error 114 | select { 115 | case sErr = <-errChan: 116 | case <-time.After(5 * time.Second): 117 | t.Fatal("timeout") 118 | } 119 | require.Error(t, sErr) 120 | var appErr *quic.ApplicationError 121 | require.True(t, errors.As(sErr, &appErr)) 122 | require.Equal(t, http3.ErrCodeFrameUnexpected, http3.ErrCode(appErr.ErrorCode)) 123 | } 124 | 125 | func TestClientInvalidSettingsHandling(t *testing.T) { 126 | for _, tc := range []struct { 127 | name string 128 | settings map[uint64]uint64 129 | errorStr string 130 | }{ 131 | { 132 | name: "Extended CONNECT disabled", 133 | settings: map[uint64]uint64{ 134 | settingDatagram: 1, 135 | settingExtendedConnect: 0, 136 | settingsEnableWebtransport: 1, 137 | }, 138 | errorStr: "server didn't enable Extended CONNECT", 139 | }, 140 | { 141 | name: "HTTP/3 DATAGRAMs disabled", 142 | settings: map[uint64]uint64{ 143 | settingDatagram: 0, 144 | settingExtendedConnect: 1, 145 | settingsEnableWebtransport: 1, 146 | }, 147 | errorStr: "server didn't enable HTTP/3 datagram support", 148 | }, 149 | { 150 | name: "WebTransport disabled", 151 | settings: map[uint64]uint64{ 152 | settingDatagram: 1, 153 | settingExtendedConnect: 1, 154 | settingsEnableWebtransport: 0, 155 | }, 156 | errorStr: "server didn't enable WebTransport", 157 | }, 158 | } { 159 | tc := tc 160 | t.Run(tc.name, func(t *testing.T) { 161 | tlsConf := tlsConf.Clone() 162 | tlsConf.NextProtos = []string{http3.NextProtoH3} 163 | ln, err := quic.ListenAddr("localhost:0", tlsConf, &quic.Config{EnableDatagrams: true}) 164 | require.NoError(t, err) 165 | defer ln.Close() 166 | 167 | done := make(chan struct{}) 168 | ctx, cancel := context.WithCancel(context.Background()) 169 | go func() { 170 | defer close(done) 171 | conn, err := ln.Accept(context.Background()) 172 | require.NoError(t, err) 173 | // send the SETTINGS frame 174 | settingsStr, err := conn.OpenUniStream() 175 | require.NoError(t, err) 176 | _, err = settingsStr.Write(appendSettingsFrame([]byte{0} /* stream type */, tc.settings)) 177 | require.NoError(t, err) 178 | if _, err := conn.AcceptStream(ctx); err == nil || !errors.Is(err, context.Canceled) { 179 | require.Fail(t, "didn't expect any stream to be accepted") 180 | } 181 | }() 182 | 183 | d := webtransport.Dialer{TLSClientConfig: &tls.Config{RootCAs: certPool}} 184 | _, _, err = d.Dial(context.Background(), fmt.Sprintf("https://localhost:%d", ln.Addr().(*net.UDPAddr).Port), nil) 185 | require.Error(t, err) 186 | require.ErrorContains(t, err, tc.errorStr) 187 | cancel() 188 | select { 189 | case <-done: 190 | case <-time.After(5 * time.Second): 191 | t.Fatal("timeout") 192 | } 193 | }) 194 | 195 | } 196 | } 197 | 198 | func TestClientReorderedUpgrade(t *testing.T) { 199 | timeout := scaleDuration(100 * time.Millisecond) 200 | blockUpgrade := make(chan struct{}) 201 | s := webtransport.Server{ 202 | H3: http3.Server{TLSConfig: tlsConf}, 203 | } 204 | addHandler(t, &s, func(c *webtransport.Session) { 205 | str, err := c.OpenStream() 206 | require.NoError(t, err) 207 | _, err = str.Write([]byte("foobar")) 208 | require.NoError(t, err) 209 | require.NoError(t, str.Close()) 210 | }) 211 | udpConn, err := net.ListenUDP("udp", nil) 212 | require.NoError(t, err) 213 | port := udpConn.LocalAddr().(*net.UDPAddr).Port 214 | go s.Serve(udpConn) 215 | 216 | d := webtransport.Dialer{ 217 | TLSClientConfig: &tls.Config{RootCAs: certPool}, 218 | QUICConfig: &quic.Config{EnableDatagrams: true}, 219 | DialAddr: func(ctx context.Context, addr string, tlsConf *tls.Config, conf *quic.Config) (quic.EarlyConnection, error) { 220 | conn, err := quic.DialAddrEarly(ctx, addr, tlsConf, conf) 221 | if err != nil { 222 | return nil, err 223 | } 224 | return &requestStreamDelayingConn{done: blockUpgrade, EarlyConnection: conn}, nil 225 | }, 226 | } 227 | sessChan := make(chan *webtransport.Session) 228 | errChan := make(chan error) 229 | go func() { 230 | // This will block until blockUpgrade is closed. 231 | rsp, sess, err := d.Dial(context.Background(), fmt.Sprintf("https://localhost:%d/webtransport", port), nil) 232 | if err != nil { 233 | errChan <- err 234 | return 235 | } 236 | require.Equal(t, 200, rsp.StatusCode) 237 | sessChan <- sess 238 | }() 239 | 240 | time.Sleep(timeout) 241 | close(blockUpgrade) 242 | var sess *webtransport.Session 243 | select { 244 | case sess = <-sessChan: 245 | case err := <-errChan: 246 | require.NoError(t, err) 247 | } 248 | defer sess.CloseWithError(0, "") 249 | ctx, cancel := context.WithTimeout(context.Background(), scaleDuration(100*time.Millisecond)) 250 | defer cancel() 251 | str, err := sess.AcceptStream(ctx) 252 | require.NoError(t, err) 253 | data, err := io.ReadAll(str) 254 | require.NoError(t, err) 255 | require.Equal(t, []byte("foobar"), data) 256 | } 257 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | coverage: 3 | status: 4 | patch: 5 | default: 6 | informational: true 7 | project: 8 | default: 9 | informational: true 10 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package webtransport 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/quic-go/quic-go" 8 | ) 9 | 10 | // StreamErrorCode is an error code used for stream termination. 11 | type StreamErrorCode uint32 12 | 13 | // SessionErrorCode is an error code for session termination. 14 | type SessionErrorCode uint32 15 | 16 | const ( 17 | firstErrorCode = 0x52e4a40fa8db 18 | lastErrorCode = 0x52e5ac983162 19 | ) 20 | 21 | func webtransportCodeToHTTPCode(n StreamErrorCode) quic.StreamErrorCode { 22 | return quic.StreamErrorCode(firstErrorCode) + quic.StreamErrorCode(n) + quic.StreamErrorCode(n/0x1e) 23 | } 24 | 25 | func httpCodeToWebtransportCode(h quic.StreamErrorCode) (StreamErrorCode, error) { 26 | if h < firstErrorCode || h > lastErrorCode { 27 | return 0, errors.New("error code outside of expected range") 28 | } 29 | if (h-0x21)%0x1f == 0 { 30 | return 0, errors.New("invalid error code") 31 | } 32 | shifted := h - firstErrorCode 33 | return StreamErrorCode(shifted - shifted/0x1f), nil 34 | } 35 | 36 | func isWebTransportError(e error) bool { 37 | if e == nil { 38 | return false 39 | } 40 | var strErr *quic.StreamError 41 | if !errors.As(e, &strErr) { 42 | return false 43 | } 44 | if strErr.ErrorCode == sessionCloseErrorCode { 45 | return true 46 | } 47 | _, err := httpCodeToWebtransportCode(strErr.ErrorCode) 48 | return err == nil 49 | } 50 | 51 | // WebTransportBufferedStreamRejectedErrorCode is the error code of the 52 | // H3_WEBTRANSPORT_BUFFERED_STREAM_REJECTED error. 53 | const WebTransportBufferedStreamRejectedErrorCode quic.StreamErrorCode = 0x3994bd84 54 | 55 | // StreamError is the error that is returned from stream operations (Read, Write) when the stream is canceled. 56 | type StreamError struct { 57 | ErrorCode StreamErrorCode 58 | Remote bool 59 | } 60 | 61 | func (e *StreamError) Is(target error) bool { 62 | _, ok := target.(*StreamError) 63 | return ok 64 | } 65 | 66 | func (e *StreamError) Error() string { 67 | return fmt.Sprintf("stream canceled with error code %d", e.ErrorCode) 68 | } 69 | 70 | // SessionError is a WebTransport connection error. 71 | type SessionError struct { 72 | Remote bool 73 | ErrorCode SessionErrorCode 74 | Message string 75 | } 76 | 77 | var _ error = &SessionError{} 78 | 79 | func (e *SessionError) Error() string { return e.Message } 80 | -------------------------------------------------------------------------------- /errors_test.go: -------------------------------------------------------------------------------- 1 | package webtransport 2 | 3 | import ( 4 | "errors" 5 | "math" 6 | "math/rand" 7 | "testing" 8 | "time" 9 | 10 | "github.com/quic-go/quic-go" 11 | 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | var random = rand.New(rand.NewSource(time.Now().UnixNano())) 16 | 17 | func TestErrorCodeRoundTrip(t *testing.T) { 18 | for i := 0; i < 1e4; i++ { 19 | n := StreamErrorCode(random.Int63()) 20 | httpCode := webtransportCodeToHTTPCode(n) 21 | errorCode, err := httpCodeToWebtransportCode(httpCode) 22 | require.NoError(t, err) 23 | require.Equal(t, n, errorCode) 24 | } 25 | } 26 | 27 | func TestErrorCodeConversionErrors(t *testing.T) { 28 | t.Run("too small", func(t *testing.T) { 29 | first, err := httpCodeToWebtransportCode(firstErrorCode) 30 | require.NoError(t, err) 31 | require.Zero(t, first) 32 | _, err = httpCodeToWebtransportCode(firstErrorCode - 1) 33 | require.EqualError(t, err, "error code outside of expected range") 34 | }) 35 | 36 | t.Run("too large", func(t *testing.T) { 37 | last, err := httpCodeToWebtransportCode(lastErrorCode) 38 | require.NoError(t, err) 39 | require.Equal(t, StreamErrorCode(math.MaxUint32), last) 40 | _, err = httpCodeToWebtransportCode(lastErrorCode + 1) 41 | require.EqualError(t, err, "error code outside of expected range") 42 | }) 43 | 44 | t.Run("greased value", func(t *testing.T) { 45 | var counter int 46 | for i := 0; i < 1e4; i++ { 47 | c := firstErrorCode + uint64(uint32(random.Int63())) 48 | if (c-0x21)%0x1f != 0 { 49 | continue 50 | } 51 | counter++ 52 | _, err := httpCodeToWebtransportCode(quic.StreamErrorCode(c)) 53 | require.EqualError(t, err, "invalid error code") 54 | } 55 | t.Logf("checked %d greased values", counter) 56 | require.NotZero(t, counter) 57 | }) 58 | } 59 | 60 | func TestErrorDetection(t *testing.T) { 61 | is := []error{ 62 | &quic.StreamError{ErrorCode: webtransportCodeToHTTPCode(42)}, 63 | &quic.StreamError{ErrorCode: sessionCloseErrorCode}, 64 | } 65 | for _, i := range is { 66 | require.True(t, isWebTransportError(i)) 67 | } 68 | 69 | isNot := []error{ 70 | errors.New("foo"), 71 | &quic.StreamError{ErrorCode: sessionCloseErrorCode + 1}, 72 | } 73 | for _, i := range isNot { 74 | require.False(t, isWebTransportError(i)) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/quic-go/webtransport-go 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/quic-go/quic-go v0.48.0 7 | github.com/stretchr/testify v1.9.0 8 | go.uber.org/mock v0.4.0 9 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 10 | ) 11 | 12 | require ( 13 | github.com/davecgh/go-spew v1.1.1 // indirect 14 | github.com/francoispqt/gojay v1.2.13 // indirect 15 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect 16 | github.com/google/pprof v0.0.0-20230821062121-407c9e7a662f // indirect 17 | github.com/kr/pretty v0.3.1 // indirect 18 | github.com/onsi/ginkgo/v2 v2.12.0 // indirect 19 | github.com/pmezard/go-difflib v1.0.0 // indirect 20 | github.com/quic-go/qpack v0.5.1 // indirect 21 | github.com/rogpeppe/go-internal v1.10.0 // indirect 22 | golang.org/x/crypto v0.26.0 // indirect 23 | golang.org/x/mod v0.17.0 // indirect 24 | golang.org/x/net v0.28.0 // indirect 25 | golang.org/x/sys v0.23.0 // indirect 26 | golang.org/x/text v0.17.0 // indirect 27 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect 28 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 29 | gopkg.in/yaml.v3 v3.0.1 // indirect 30 | ) 31 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 3 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 4 | cloud.google.com/go v0.37.0/go.mod h1:TS1dMSSfndXH133OKGwekG838Om/cQT0BUHV3HcBgoo= 5 | dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl+fi1br7+Rr3LqpNJf1/uxUdtRUV+Tnj0o93V2B9MU= 6 | dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU= 7 | dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4= 8 | dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU= 9 | git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= 10 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 11 | github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= 12 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 13 | github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= 14 | github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= 15 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 16 | github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 17 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 18 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 20 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 21 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 22 | github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= 23 | github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= 24 | github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= 25 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 26 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 27 | github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= 28 | github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= 29 | github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= 30 | github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 31 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= 32 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= 33 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 34 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 35 | github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= 36 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 37 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 38 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 39 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 40 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 41 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 42 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 43 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 44 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 45 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 46 | github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= 47 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 48 | github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= 49 | github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 50 | github.com/google/pprof v0.0.0-20230821062121-407c9e7a662f h1:pDhu5sgp8yJlEF/g6osliIIpF9K4F5jvkULXa4daRDQ= 51 | github.com/google/pprof v0.0.0-20230821062121-407c9e7a662f/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= 52 | github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= 53 | github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg= 54 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 55 | github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= 56 | github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= 57 | github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= 58 | github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= 59 | github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= 60 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 61 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 62 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 63 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 64 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 65 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 66 | github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 67 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 68 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 69 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 70 | github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= 71 | github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= 72 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 73 | github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= 74 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 75 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 76 | github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= 77 | github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= 78 | github.com/onsi/ginkgo/v2 v2.12.0 h1:UIVDowFPwpg6yMUpPjGkYvf06K3RAiJXUhCxEwQVHRI= 79 | github.com/onsi/ginkgo/v2 v2.12.0/go.mod h1:ZNEzXISYlqpb8S36iN71ifqLi3vVD1rVJGvWRCJOUpQ= 80 | github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= 81 | github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= 82 | github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= 83 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 84 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 85 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 86 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 87 | github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 88 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 89 | github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= 90 | github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 91 | github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= 92 | github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= 93 | github.com/quic-go/quic-go v0.48.0 h1:2TCyvBrMu1Z25rvIAlnp2dPT4lgh/uTqLqiXVpp5AeU= 94 | github.com/quic-go/quic-go v0.48.0/go.mod h1:yBgs3rWBOADpga7F+jJsb6Ybg1LSYiQvwWlLX+/6HMs= 95 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 96 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 97 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 98 | github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= 99 | github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= 100 | github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY= 101 | github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM= 102 | github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0= 103 | github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= 104 | github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= 105 | github.com/shurcooL/gofontwoff v0.0.0-20180329035133-29b52fc0a18d/go.mod h1:05UtEgK5zq39gLST6uB0cf3NEHjETfB4Fgr3Gx5R9Vw= 106 | github.com/shurcooL/gopherjslib v0.0.0-20160914041154-feb6d3990c2c/go.mod h1:8d3azKNyqcHP1GaQE/c6dDgjkgSx2BZ4IoEi4F1reUI= 107 | github.com/shurcooL/highlight_diff v0.0.0-20170515013008-09bb4053de1b/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU= 108 | github.com/shurcooL/highlight_go v0.0.0-20181028180052-98c3abbbae20/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag= 109 | github.com/shurcooL/home v0.0.0-20181020052607-80b7ffcb30f9/go.mod h1:+rgNQw2P9ARFAs37qieuu7ohDNQ3gds9msbT2yn85sg= 110 | github.com/shurcooL/htmlg v0.0.0-20170918183704-d01228ac9e50/go.mod h1:zPn1wHpTIePGnXSHpsVPWEktKXHr6+SS6x/IKRb7cpw= 111 | github.com/shurcooL/httperror v0.0.0-20170206035902-86b7830d14cc/go.mod h1:aYMfkZ6DWSJPJ6c4Wwz3QtW22G7mf/PEgaB9k/ik5+Y= 112 | github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= 113 | github.com/shurcooL/httpgzip v0.0.0-20180522190206-b1c53ac65af9/go.mod h1:919LwcH0M7/W4fcZ0/jy0qGght1GIhqyS/EgWGH2j5Q= 114 | github.com/shurcooL/issues v0.0.0-20181008053335-6292fdc1e191/go.mod h1:e2qWDig5bLteJ4fwvDAc2NHzqFEthkqn7aOZAOpj+PQ= 115 | github.com/shurcooL/issuesapp v0.0.0-20180602232740-048589ce2241/go.mod h1:NPpHK2TI7iSaM0buivtFUc9offApnI0Alt/K8hcHy0I= 116 | github.com/shurcooL/notifications v0.0.0-20181007000457-627ab5aea122/go.mod h1:b5uSkrEVM1jQUspwbixRBhaIjIzL2xazXp6kntxYle0= 117 | github.com/shurcooL/octicon v0.0.0-20181028054416-fa4f57f9efb2/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ= 118 | github.com/shurcooL/reactions v0.0.0-20181006231557-f2e0b4ca5b82/go.mod h1:TCR1lToEk4d2s07G3XGfz2QrgHXg4RJBvjrOozvoWfk= 119 | github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 120 | github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4= 121 | github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw= 122 | github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= 123 | github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= 124 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 125 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 126 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 127 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 128 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 129 | github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= 130 | github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= 131 | github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM= 132 | go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= 133 | go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= 134 | go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= 135 | go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= 136 | golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw= 137 | golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 138 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 139 | golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 140 | golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= 141 | golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= 142 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 143 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= 144 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= 145 | golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 146 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 147 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 148 | golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= 149 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 150 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 151 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 152 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 153 | golang.org/x/net v0.0.0-20181029044818-c44066c5c816/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 154 | golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 155 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 156 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 157 | golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 158 | golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= 159 | golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= 160 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 161 | golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 162 | golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 163 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 164 | golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw= 165 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 166 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 167 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 168 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 169 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 170 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 171 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 172 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 173 | golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 174 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 175 | golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 176 | golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= 177 | golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 178 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 179 | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 180 | golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= 181 | golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 182 | golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 183 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 184 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 185 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 186 | golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 187 | golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 188 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 189 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 190 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 191 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 192 | google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= 193 | google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= 194 | google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= 195 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 196 | google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 197 | google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 198 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 199 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 200 | google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 201 | google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 202 | google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= 203 | google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 204 | google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= 205 | google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= 206 | google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= 207 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 208 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 209 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 210 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 211 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 212 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 213 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 214 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 215 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 216 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 217 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 218 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 219 | grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= 220 | honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 221 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 222 | honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 223 | sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck= 224 | sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= 225 | -------------------------------------------------------------------------------- /interop/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /interop/interop.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | 5 | from selenium import webdriver 6 | from selenium.webdriver.chrome.service import Service 7 | from webdriver_manager.chrome import ChromeDriverManager 8 | from selenium.webdriver.common.desired_capabilities import DesiredCapabilities 9 | from selenium.webdriver.support.ui import WebDriverWait 10 | from selenium.webdriver.support import expected_conditions 11 | from selenium.webdriver.common.by import By 12 | from selenium.common.exceptions import TimeoutException 13 | 14 | chrome_loc = "/usr/bin/google-chrome" 15 | if sys.platform == "darwin": 16 | chrome_loc = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" 17 | 18 | options = webdriver.ChromeOptions() 19 | options.gpu = False 20 | options.binary_location = chrome_loc 21 | options.add_argument("--no-sandbox") 22 | options.add_argument("--enable-quic") 23 | options.add_argument("--headless") 24 | options.add_argument("--origin-to-force-quic-on=localhost:12345") 25 | options.add_argument("--host-resolver-rules='MAP localhost:12345 127.0.0.1:12345'") 26 | options.set_capability("goog:loggingPrefs", {"browser": "ALL"}) 27 | 28 | driver = webdriver.Chrome( 29 | service=Service(ChromeDriverManager().install()), 30 | options=options, 31 | ) 32 | driver.get("http://localhost:8080/webtransport") 33 | 34 | delay = 5 35 | failed = False 36 | try: 37 | # when the test finishes successfully, it adds a div#done to the body 38 | myElem = WebDriverWait(driver, delay).until( 39 | expected_conditions.presence_of_element_located((By.ID, "done")) 40 | ) 41 | print("Test succeeded!") 42 | except TimeoutException: 43 | failed = True 44 | print("Test timed out.") 45 | 46 | # for debugging, print all the console messages 47 | for entry in driver.get_log("browser"): 48 | print(entry) 49 | 50 | driver.quit() 51 | 52 | if failed: 53 | sys.exit(1) 54 | -------------------------------------------------------------------------------- /interop/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/ecdsa" 7 | "crypto/elliptic" 8 | "crypto/rand" 9 | "crypto/sha256" 10 | "crypto/tls" 11 | "crypto/x509" 12 | "crypto/x509/pkix" 13 | _ "embed" 14 | "encoding/binary" 15 | "fmt" 16 | "io" 17 | "log" 18 | "math/big" 19 | "net/http" 20 | "strings" 21 | "time" 22 | 23 | "github.com/quic-go/quic-go/http3" 24 | 25 | "github.com/quic-go/webtransport-go" 26 | ) 27 | 28 | //go:embed index.html 29 | var indexHTML string 30 | 31 | var data []byte 32 | 33 | func init() { 34 | data = make([]byte, 1<<20) 35 | rand.Read(data) 36 | } 37 | 38 | func main() { 39 | tlsConf, err := getTLSConf(time.Now(), time.Now().Add(10*24*time.Hour)) 40 | if err != nil { 41 | log.Fatal(err) 42 | } 43 | hash := sha256.Sum256(tlsConf.Certificates[0].Leaf.Raw) 44 | 45 | go runHTTPServer(hash) 46 | 47 | wmux := http.NewServeMux() 48 | s := webtransport.Server{ 49 | H3: http3.Server{ 50 | TLSConfig: tlsConf, 51 | Addr: "localhost:12345", 52 | Handler: wmux, 53 | }, 54 | CheckOrigin: func(r *http.Request) bool { return true }, 55 | } 56 | defer s.Close() 57 | 58 | wmux.HandleFunc("/unidirectional", func(w http.ResponseWriter, r *http.Request) { 59 | conn, err := s.Upgrade(w, r) 60 | if err != nil { 61 | log.Printf("upgrading failed: %s", err) 62 | w.WriteHeader(500) 63 | return 64 | } 65 | runUnidirectionalTest(conn) 66 | }) 67 | if err := s.ListenAndServe(); err != nil { 68 | log.Fatal(err) 69 | } 70 | } 71 | 72 | func runHTTPServer(certHash [32]byte) { 73 | mux := http.NewServeMux() 74 | mux.HandleFunc("/webtransport", func(w http.ResponseWriter, _ *http.Request) { 75 | fmt.Println("handler hit") 76 | content := strings.ReplaceAll(indexHTML, "%%CERTHASH%%", formatByteSlice(certHash[:])) 77 | content = strings.ReplaceAll(content, "%%DATA%%", formatByteSlice(data)) 78 | content = strings.ReplaceAll(content, "%%TEST%%", "unidirectional") 79 | w.Write([]byte(content)) 80 | }) 81 | http.ListenAndServe("localhost:8080", mux) 82 | } 83 | 84 | func runUnidirectionalTest(sess *webtransport.Session) { 85 | for i := 0; i < 5; i++ { 86 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 87 | defer cancel() 88 | 89 | str, err := sess.AcceptUniStream(ctx) 90 | if err != nil { 91 | log.Fatalf("failed to accept unidirectional stream: %v", err) 92 | } 93 | rvcd, err := io.ReadAll(str) 94 | if err != nil { 95 | log.Fatalf("failed to read all data: %v", err) 96 | } 97 | if !bytes.Equal(rvcd, data) { 98 | log.Fatal("data doesn't match") 99 | } 100 | } 101 | select { 102 | case <-sess.Context().Done(): 103 | fmt.Println("done") 104 | case <-time.After(5 * time.Second): 105 | log.Fatal("timed out waiting for the session to be closed") 106 | } 107 | } 108 | 109 | func getTLSConf(start, end time.Time) (*tls.Config, error) { 110 | cert, priv, err := generateCert(start, end) 111 | if err != nil { 112 | return nil, err 113 | } 114 | return &tls.Config{ 115 | Certificates: []tls.Certificate{{ 116 | Certificate: [][]byte{cert.Raw}, 117 | PrivateKey: priv, 118 | Leaf: cert, 119 | }}, 120 | }, nil 121 | } 122 | 123 | func generateCert(start, end time.Time) (*x509.Certificate, *ecdsa.PrivateKey, error) { 124 | b := make([]byte, 8) 125 | if _, err := rand.Read(b); err != nil { 126 | return nil, nil, err 127 | } 128 | serial := int64(binary.BigEndian.Uint64(b)) 129 | if serial < 0 { 130 | serial = -serial 131 | } 132 | certTempl := &x509.Certificate{ 133 | SerialNumber: big.NewInt(serial), 134 | Subject: pkix.Name{}, 135 | NotBefore: start, 136 | NotAfter: end, 137 | IsCA: true, 138 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, 139 | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, 140 | BasicConstraintsValid: true, 141 | } 142 | caPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 143 | if err != nil { 144 | return nil, nil, err 145 | } 146 | caBytes, err := x509.CreateCertificate(rand.Reader, certTempl, certTempl, &caPrivateKey.PublicKey, caPrivateKey) 147 | if err != nil { 148 | return nil, nil, err 149 | } 150 | ca, err := x509.ParseCertificate(caBytes) 151 | if err != nil { 152 | return nil, nil, err 153 | } 154 | return ca, caPrivateKey, nil 155 | } 156 | 157 | func formatByteSlice(b []byte) string { 158 | s := strings.ReplaceAll(fmt.Sprintf("%#v", b[:]), "[]byte{", "[") 159 | s = strings.ReplaceAll(s, "}", "]") 160 | return s 161 | } 162 | -------------------------------------------------------------------------------- /interop/requirements.txt: -------------------------------------------------------------------------------- 1 | packaging 2 | selenium 3 | webdriver-manager 4 | -------------------------------------------------------------------------------- /mock_connection_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/quic-go/quic-go/http3 (interfaces: Connection) 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -package webtransport -destination mock_connection_test.go github.com/quic-go/quic-go/http3 Connection 7 | // 8 | 9 | // Package webtransport is a generated GoMock package. 10 | package webtransport 11 | 12 | import ( 13 | context "context" 14 | net "net" 15 | reflect "reflect" 16 | 17 | quic "github.com/quic-go/quic-go" 18 | http3 "github.com/quic-go/quic-go/http3" 19 | gomock "go.uber.org/mock/gomock" 20 | ) 21 | 22 | // MockConnection is a mock of Connection interface. 23 | type MockConnection struct { 24 | ctrl *gomock.Controller 25 | recorder *MockConnectionMockRecorder 26 | } 27 | 28 | // MockConnectionMockRecorder is the mock recorder for MockConnection. 29 | type MockConnectionMockRecorder struct { 30 | mock *MockConnection 31 | } 32 | 33 | // NewMockConnection creates a new mock instance. 34 | func NewMockConnection(ctrl *gomock.Controller) *MockConnection { 35 | mock := &MockConnection{ctrl: ctrl} 36 | mock.recorder = &MockConnectionMockRecorder{mock} 37 | return mock 38 | } 39 | 40 | // EXPECT returns an object that allows the caller to indicate expected use. 41 | func (m *MockConnection) EXPECT() *MockConnectionMockRecorder { 42 | return m.recorder 43 | } 44 | 45 | // CloseWithError mocks base method. 46 | func (m *MockConnection) CloseWithError(arg0 quic.ApplicationErrorCode, arg1 string) error { 47 | m.ctrl.T.Helper() 48 | ret := m.ctrl.Call(m, "CloseWithError", arg0, arg1) 49 | ret0, _ := ret[0].(error) 50 | return ret0 51 | } 52 | 53 | // CloseWithError indicates an expected call of CloseWithError. 54 | func (mr *MockConnectionMockRecorder) CloseWithError(arg0, arg1 any) *gomock.Call { 55 | mr.mock.ctrl.T.Helper() 56 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CloseWithError", reflect.TypeOf((*MockConnection)(nil).CloseWithError), arg0, arg1) 57 | } 58 | 59 | // ConnectionState mocks base method. 60 | func (m *MockConnection) ConnectionState() quic.ConnectionState { 61 | m.ctrl.T.Helper() 62 | ret := m.ctrl.Call(m, "ConnectionState") 63 | ret0, _ := ret[0].(quic.ConnectionState) 64 | return ret0 65 | } 66 | 67 | // ConnectionState indicates an expected call of ConnectionState. 68 | func (mr *MockConnectionMockRecorder) ConnectionState() *gomock.Call { 69 | mr.mock.ctrl.T.Helper() 70 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConnectionState", reflect.TypeOf((*MockConnection)(nil).ConnectionState)) 71 | } 72 | 73 | // Context mocks base method. 74 | func (m *MockConnection) Context() context.Context { 75 | m.ctrl.T.Helper() 76 | ret := m.ctrl.Call(m, "Context") 77 | ret0, _ := ret[0].(context.Context) 78 | return ret0 79 | } 80 | 81 | // Context indicates an expected call of Context. 82 | func (mr *MockConnectionMockRecorder) Context() *gomock.Call { 83 | mr.mock.ctrl.T.Helper() 84 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Context", reflect.TypeOf((*MockConnection)(nil).Context)) 85 | } 86 | 87 | // LocalAddr mocks base method. 88 | func (m *MockConnection) LocalAddr() net.Addr { 89 | m.ctrl.T.Helper() 90 | ret := m.ctrl.Call(m, "LocalAddr") 91 | ret0, _ := ret[0].(net.Addr) 92 | return ret0 93 | } 94 | 95 | // LocalAddr indicates an expected call of LocalAddr. 96 | func (mr *MockConnectionMockRecorder) LocalAddr() *gomock.Call { 97 | mr.mock.ctrl.T.Helper() 98 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LocalAddr", reflect.TypeOf((*MockConnection)(nil).LocalAddr)) 99 | } 100 | 101 | // OpenStream mocks base method. 102 | func (m *MockConnection) OpenStream() (quic.Stream, error) { 103 | m.ctrl.T.Helper() 104 | ret := m.ctrl.Call(m, "OpenStream") 105 | ret0, _ := ret[0].(quic.Stream) 106 | ret1, _ := ret[1].(error) 107 | return ret0, ret1 108 | } 109 | 110 | // OpenStream indicates an expected call of OpenStream. 111 | func (mr *MockConnectionMockRecorder) OpenStream() *gomock.Call { 112 | mr.mock.ctrl.T.Helper() 113 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenStream", reflect.TypeOf((*MockConnection)(nil).OpenStream)) 114 | } 115 | 116 | // OpenStreamSync mocks base method. 117 | func (m *MockConnection) OpenStreamSync(arg0 context.Context) (quic.Stream, error) { 118 | m.ctrl.T.Helper() 119 | ret := m.ctrl.Call(m, "OpenStreamSync", arg0) 120 | ret0, _ := ret[0].(quic.Stream) 121 | ret1, _ := ret[1].(error) 122 | return ret0, ret1 123 | } 124 | 125 | // OpenStreamSync indicates an expected call of OpenStreamSync. 126 | func (mr *MockConnectionMockRecorder) OpenStreamSync(arg0 any) *gomock.Call { 127 | mr.mock.ctrl.T.Helper() 128 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenStreamSync", reflect.TypeOf((*MockConnection)(nil).OpenStreamSync), arg0) 129 | } 130 | 131 | // OpenUniStream mocks base method. 132 | func (m *MockConnection) OpenUniStream() (quic.SendStream, error) { 133 | m.ctrl.T.Helper() 134 | ret := m.ctrl.Call(m, "OpenUniStream") 135 | ret0, _ := ret[0].(quic.SendStream) 136 | ret1, _ := ret[1].(error) 137 | return ret0, ret1 138 | } 139 | 140 | // OpenUniStream indicates an expected call of OpenUniStream. 141 | func (mr *MockConnectionMockRecorder) OpenUniStream() *gomock.Call { 142 | mr.mock.ctrl.T.Helper() 143 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenUniStream", reflect.TypeOf((*MockConnection)(nil).OpenUniStream)) 144 | } 145 | 146 | // OpenUniStreamSync mocks base method. 147 | func (m *MockConnection) OpenUniStreamSync(arg0 context.Context) (quic.SendStream, error) { 148 | m.ctrl.T.Helper() 149 | ret := m.ctrl.Call(m, "OpenUniStreamSync", arg0) 150 | ret0, _ := ret[0].(quic.SendStream) 151 | ret1, _ := ret[1].(error) 152 | return ret0, ret1 153 | } 154 | 155 | // OpenUniStreamSync indicates an expected call of OpenUniStreamSync. 156 | func (mr *MockConnectionMockRecorder) OpenUniStreamSync(arg0 any) *gomock.Call { 157 | mr.mock.ctrl.T.Helper() 158 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenUniStreamSync", reflect.TypeOf((*MockConnection)(nil).OpenUniStreamSync), arg0) 159 | } 160 | 161 | // ReceivedSettings mocks base method. 162 | func (m *MockConnection) ReceivedSettings() <-chan struct{} { 163 | m.ctrl.T.Helper() 164 | ret := m.ctrl.Call(m, "ReceivedSettings") 165 | ret0, _ := ret[0].(<-chan struct{}) 166 | return ret0 167 | } 168 | 169 | // ReceivedSettings indicates an expected call of ReceivedSettings. 170 | func (mr *MockConnectionMockRecorder) ReceivedSettings() *gomock.Call { 171 | mr.mock.ctrl.T.Helper() 172 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReceivedSettings", reflect.TypeOf((*MockConnection)(nil).ReceivedSettings)) 173 | } 174 | 175 | // RemoteAddr mocks base method. 176 | func (m *MockConnection) RemoteAddr() net.Addr { 177 | m.ctrl.T.Helper() 178 | ret := m.ctrl.Call(m, "RemoteAddr") 179 | ret0, _ := ret[0].(net.Addr) 180 | return ret0 181 | } 182 | 183 | // RemoteAddr indicates an expected call of RemoteAddr. 184 | func (mr *MockConnectionMockRecorder) RemoteAddr() *gomock.Call { 185 | mr.mock.ctrl.T.Helper() 186 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoteAddr", reflect.TypeOf((*MockConnection)(nil).RemoteAddr)) 187 | } 188 | 189 | // Settings mocks base method. 190 | func (m *MockConnection) Settings() *http3.Settings { 191 | m.ctrl.T.Helper() 192 | ret := m.ctrl.Call(m, "Settings") 193 | ret0, _ := ret[0].(*http3.Settings) 194 | return ret0 195 | } 196 | 197 | // Settings indicates an expected call of Settings. 198 | func (mr *MockConnectionMockRecorder) Settings() *gomock.Call { 199 | mr.mock.ctrl.T.Helper() 200 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Settings", reflect.TypeOf((*MockConnection)(nil).Settings)) 201 | } 202 | -------------------------------------------------------------------------------- /mock_stream_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/quic-go/quic-go/http3 (interfaces: Stream) 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -package webtransport -destination mock_stream_test.go github.com/quic-go/quic-go/http3 Stream 7 | // 8 | 9 | // Package webtransport is a generated GoMock package. 10 | package webtransport 11 | 12 | import ( 13 | context "context" 14 | reflect "reflect" 15 | time "time" 16 | 17 | "github.com/quic-go/quic-go" 18 | gomock "go.uber.org/mock/gomock" 19 | ) 20 | 21 | // MockStream is a mock of Stream interface. 22 | type MockStream struct { 23 | ctrl *gomock.Controller 24 | recorder *MockStreamMockRecorder 25 | } 26 | 27 | // MockStreamMockRecorder is the mock recorder for MockStream. 28 | type MockStreamMockRecorder struct { 29 | mock *MockStream 30 | } 31 | 32 | // NewMockStream creates a new mock instance. 33 | func NewMockStream(ctrl *gomock.Controller) *MockStream { 34 | mock := &MockStream{ctrl: ctrl} 35 | mock.recorder = &MockStreamMockRecorder{mock} 36 | return mock 37 | } 38 | 39 | // EXPECT returns an object that allows the caller to indicate expected use. 40 | func (m *MockStream) EXPECT() *MockStreamMockRecorder { 41 | return m.recorder 42 | } 43 | 44 | // CancelRead mocks base method. 45 | func (m *MockStream) CancelRead(arg0 quic.StreamErrorCode) { 46 | m.ctrl.T.Helper() 47 | m.ctrl.Call(m, "CancelRead", arg0) 48 | } 49 | 50 | // CancelRead indicates an expected call of CancelRead. 51 | func (mr *MockStreamMockRecorder) CancelRead(arg0 any) *gomock.Call { 52 | mr.mock.ctrl.T.Helper() 53 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CancelRead", reflect.TypeOf((*MockStream)(nil).CancelRead), arg0) 54 | } 55 | 56 | // CancelWrite mocks base method. 57 | func (m *MockStream) CancelWrite(arg0 quic.StreamErrorCode) { 58 | m.ctrl.T.Helper() 59 | m.ctrl.Call(m, "CancelWrite", arg0) 60 | } 61 | 62 | // CancelWrite indicates an expected call of CancelWrite. 63 | func (mr *MockStreamMockRecorder) CancelWrite(arg0 any) *gomock.Call { 64 | mr.mock.ctrl.T.Helper() 65 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CancelWrite", reflect.TypeOf((*MockStream)(nil).CancelWrite), arg0) 66 | } 67 | 68 | // Close mocks base method. 69 | func (m *MockStream) Close() error { 70 | m.ctrl.T.Helper() 71 | ret := m.ctrl.Call(m, "Close") 72 | ret0, _ := ret[0].(error) 73 | return ret0 74 | } 75 | 76 | // Close indicates an expected call of Close. 77 | func (mr *MockStreamMockRecorder) Close() *gomock.Call { 78 | mr.mock.ctrl.T.Helper() 79 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockStream)(nil).Close)) 80 | } 81 | 82 | // Context mocks base method. 83 | func (m *MockStream) Context() context.Context { 84 | m.ctrl.T.Helper() 85 | ret := m.ctrl.Call(m, "Context") 86 | ret0, _ := ret[0].(context.Context) 87 | return ret0 88 | } 89 | 90 | // Context indicates an expected call of Context. 91 | func (mr *MockStreamMockRecorder) Context() *gomock.Call { 92 | mr.mock.ctrl.T.Helper() 93 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Context", reflect.TypeOf((*MockStream)(nil).Context)) 94 | } 95 | 96 | // Read mocks base method. 97 | func (m *MockStream) Read(arg0 []byte) (int, error) { 98 | m.ctrl.T.Helper() 99 | ret := m.ctrl.Call(m, "Read", arg0) 100 | ret0, _ := ret[0].(int) 101 | ret1, _ := ret[1].(error) 102 | return ret0, ret1 103 | } 104 | 105 | // Read indicates an expected call of Read. 106 | func (mr *MockStreamMockRecorder) Read(arg0 any) *gomock.Call { 107 | mr.mock.ctrl.T.Helper() 108 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Read", reflect.TypeOf((*MockStream)(nil).Read), arg0) 109 | } 110 | 111 | // ReceiveDatagram mocks base method. 112 | func (m *MockStream) ReceiveDatagram(arg0 context.Context) ([]byte, error) { 113 | m.ctrl.T.Helper() 114 | ret := m.ctrl.Call(m, "ReceiveDatagram", arg0) 115 | ret0, _ := ret[0].([]byte) 116 | ret1, _ := ret[1].(error) 117 | return ret0, ret1 118 | } 119 | 120 | // ReceiveDatagram indicates an expected call of ReceiveDatagram. 121 | func (mr *MockStreamMockRecorder) ReceiveDatagram(arg0 any) *gomock.Call { 122 | mr.mock.ctrl.T.Helper() 123 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReceiveDatagram", reflect.TypeOf((*MockStream)(nil).ReceiveDatagram), arg0) 124 | } 125 | 126 | // SendDatagram mocks base method. 127 | func (m *MockStream) SendDatagram(arg0 []byte) error { 128 | m.ctrl.T.Helper() 129 | ret := m.ctrl.Call(m, "SendDatagram", arg0) 130 | ret0, _ := ret[0].(error) 131 | return ret0 132 | } 133 | 134 | // SendDatagram indicates an expected call of SendDatagram. 135 | func (mr *MockStreamMockRecorder) SendDatagram(arg0 any) *gomock.Call { 136 | mr.mock.ctrl.T.Helper() 137 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendDatagram", reflect.TypeOf((*MockStream)(nil).SendDatagram), arg0) 138 | } 139 | 140 | // SetDeadline mocks base method. 141 | func (m *MockStream) SetDeadline(arg0 time.Time) error { 142 | m.ctrl.T.Helper() 143 | ret := m.ctrl.Call(m, "SetDeadline", arg0) 144 | ret0, _ := ret[0].(error) 145 | return ret0 146 | } 147 | 148 | // SetDeadline indicates an expected call of SetDeadline. 149 | func (mr *MockStreamMockRecorder) SetDeadline(arg0 any) *gomock.Call { 150 | mr.mock.ctrl.T.Helper() 151 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetDeadline", reflect.TypeOf((*MockStream)(nil).SetDeadline), arg0) 152 | } 153 | 154 | // SetReadDeadline mocks base method. 155 | func (m *MockStream) SetReadDeadline(arg0 time.Time) error { 156 | m.ctrl.T.Helper() 157 | ret := m.ctrl.Call(m, "SetReadDeadline", arg0) 158 | ret0, _ := ret[0].(error) 159 | return ret0 160 | } 161 | 162 | // SetReadDeadline indicates an expected call of SetReadDeadline. 163 | func (mr *MockStreamMockRecorder) SetReadDeadline(arg0 any) *gomock.Call { 164 | mr.mock.ctrl.T.Helper() 165 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetReadDeadline", reflect.TypeOf((*MockStream)(nil).SetReadDeadline), arg0) 166 | } 167 | 168 | // SetWriteDeadline mocks base method. 169 | func (m *MockStream) SetWriteDeadline(arg0 time.Time) error { 170 | m.ctrl.T.Helper() 171 | ret := m.ctrl.Call(m, "SetWriteDeadline", arg0) 172 | ret0, _ := ret[0].(error) 173 | return ret0 174 | } 175 | 176 | // SetWriteDeadline indicates an expected call of SetWriteDeadline. 177 | func (mr *MockStreamMockRecorder) SetWriteDeadline(arg0 any) *gomock.Call { 178 | mr.mock.ctrl.T.Helper() 179 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetWriteDeadline", reflect.TypeOf((*MockStream)(nil).SetWriteDeadline), arg0) 180 | } 181 | 182 | // StreamID mocks base method. 183 | func (m *MockStream) StreamID() quic.StreamID { 184 | m.ctrl.T.Helper() 185 | ret := m.ctrl.Call(m, "StreamID") 186 | ret0, _ := ret[0].(quic.StreamID) 187 | return ret0 188 | } 189 | 190 | // StreamID indicates an expected call of StreamID. 191 | func (mr *MockStreamMockRecorder) StreamID() *gomock.Call { 192 | mr.mock.ctrl.T.Helper() 193 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StreamID", reflect.TypeOf((*MockStream)(nil).StreamID)) 194 | } 195 | 196 | // Write mocks base method. 197 | func (m *MockStream) Write(arg0 []byte) (int, error) { 198 | m.ctrl.T.Helper() 199 | ret := m.ctrl.Call(m, "Write", arg0) 200 | ret0, _ := ret[0].(int) 201 | ret1, _ := ret[1].(error) 202 | return ret0, ret1 203 | } 204 | 205 | // Write indicates an expected call of Write. 206 | func (mr *MockStreamMockRecorder) Write(arg0 any) *gomock.Call { 207 | mr.mock.ctrl.T.Helper() 208 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Write", reflect.TypeOf((*MockStream)(nil).Write), arg0) 209 | } 210 | -------------------------------------------------------------------------------- /protocol.go: -------------------------------------------------------------------------------- 1 | package webtransport 2 | 3 | const settingsEnableWebtransport = 0x2b603742 4 | 5 | const protocolHeader = "webtransport" 6 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package webtransport 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "net/http" 9 | "net/url" 10 | "sync" 11 | "time" 12 | "unicode/utf8" 13 | 14 | "github.com/quic-go/quic-go" 15 | "github.com/quic-go/quic-go/http3" 16 | "github.com/quic-go/quic-go/quicvarint" 17 | ) 18 | 19 | const ( 20 | webTransportDraftOfferHeaderKey = "Sec-Webtransport-Http3-Draft02" 21 | webTransportDraftHeaderKey = "Sec-Webtransport-Http3-Draft" 22 | webTransportDraftHeaderValue = "draft02" 23 | ) 24 | 25 | const ( 26 | webTransportFrameType = 0x41 27 | webTransportUniStreamType = 0x54 28 | ) 29 | 30 | type Server struct { 31 | H3 http3.Server 32 | 33 | // ReorderingTimeout is the maximum time an incoming WebTransport stream that cannot be associated 34 | // with a session is buffered. It is also the maximum time a WebTransport connection request is 35 | // blocked waiting for the client's SETTINGS are received. 36 | // This can happen if the CONNECT request (that creates a new session) is reordered, and arrives 37 | // after the first WebTransport stream(s) for that session. 38 | // Defaults to 5 seconds. 39 | ReorderingTimeout time.Duration 40 | 41 | // CheckOrigin is used to validate the request origin, thereby preventing cross-site request forgery. 42 | // CheckOrigin returns true if the request Origin header is acceptable. 43 | // If unset, a safe default is used: If the Origin header is set, it is checked that it 44 | // matches the request's Host header. 45 | CheckOrigin func(r *http.Request) bool 46 | 47 | ctx context.Context // is closed when Close is called 48 | ctxCancel context.CancelFunc 49 | refCount sync.WaitGroup 50 | 51 | initOnce sync.Once 52 | initErr error 53 | 54 | conns *sessionManager 55 | } 56 | 57 | func (s *Server) initialize() error { 58 | s.initOnce.Do(func() { 59 | s.initErr = s.init() 60 | }) 61 | return s.initErr 62 | } 63 | 64 | func (s *Server) timeout() time.Duration { 65 | timeout := s.ReorderingTimeout 66 | if timeout == 0 { 67 | return 5 * time.Second 68 | } 69 | return timeout 70 | } 71 | 72 | func (s *Server) init() error { 73 | s.ctx, s.ctxCancel = context.WithCancel(context.Background()) 74 | 75 | s.conns = newSessionManager(s.timeout()) 76 | if s.CheckOrigin == nil { 77 | s.CheckOrigin = checkSameOrigin 78 | } 79 | 80 | // configure the http3.Server 81 | if s.H3.AdditionalSettings == nil { 82 | s.H3.AdditionalSettings = make(map[uint64]uint64, 1) 83 | } 84 | s.H3.AdditionalSettings[settingsEnableWebtransport] = 1 85 | s.H3.EnableDatagrams = true 86 | if s.H3.StreamHijacker != nil { 87 | return errors.New("StreamHijacker already set") 88 | } 89 | s.H3.StreamHijacker = func(ft http3.FrameType, connTracingID quic.ConnectionTracingID, str quic.Stream, err error) (bool /* hijacked */, error) { 90 | if isWebTransportError(err) { 91 | return true, nil 92 | } 93 | if ft != webTransportFrameType { 94 | return false, nil 95 | } 96 | // Reading the varint might block if the peer sends really small frames, but this is fine. 97 | // This function is called from the HTTP/3 request handler, which runs in its own Go routine. 98 | id, err := quicvarint.Read(quicvarint.NewReader(str)) 99 | if err != nil { 100 | if isWebTransportError(err) { 101 | return true, nil 102 | } 103 | return false, err 104 | } 105 | s.conns.AddStream(connTracingID, str, sessionID(id)) 106 | return true, nil 107 | } 108 | s.H3.UniStreamHijacker = func(st http3.StreamType, connTracingID quic.ConnectionTracingID, str quic.ReceiveStream, err error) (hijacked bool) { 109 | if st != webTransportUniStreamType && !isWebTransportError(err) { 110 | return false 111 | } 112 | s.conns.AddUniStream(connTracingID, str) 113 | return true 114 | } 115 | return nil 116 | } 117 | 118 | func (s *Server) Serve(conn net.PacketConn) error { 119 | if err := s.initialize(); err != nil { 120 | return err 121 | } 122 | return s.H3.Serve(conn) 123 | } 124 | 125 | // ServeQUICConn serves a single QUIC connection. 126 | func (s *Server) ServeQUICConn(conn quic.Connection) error { 127 | if err := s.initialize(); err != nil { 128 | return err 129 | } 130 | return s.H3.ServeQUICConn(conn) 131 | } 132 | 133 | func (s *Server) ListenAndServe() error { 134 | if err := s.initialize(); err != nil { 135 | return err 136 | } 137 | return s.H3.ListenAndServe() 138 | } 139 | 140 | func (s *Server) ListenAndServeTLS(certFile, keyFile string) error { 141 | if err := s.initialize(); err != nil { 142 | return err 143 | } 144 | return s.H3.ListenAndServeTLS(certFile, keyFile) 145 | } 146 | 147 | func (s *Server) Close() error { 148 | // Make sure that ctxCancel is defined. 149 | // This is expected to be uncommon. 150 | // It only happens if the server is closed without Serve / ListenAndServe having been called. 151 | s.initOnce.Do(func() {}) 152 | 153 | if s.ctxCancel != nil { 154 | s.ctxCancel() 155 | } 156 | if s.conns != nil { 157 | s.conns.Close() 158 | } 159 | err := s.H3.Close() 160 | s.refCount.Wait() 161 | return err 162 | } 163 | 164 | func (s *Server) Upgrade(w http.ResponseWriter, r *http.Request) (*Session, error) { 165 | if r.Method != http.MethodConnect { 166 | return nil, fmt.Errorf("expected CONNECT request, got %s", r.Method) 167 | } 168 | if r.Proto != protocolHeader { 169 | return nil, fmt.Errorf("unexpected protocol: %s", r.Proto) 170 | } 171 | if v, ok := r.Header[webTransportDraftOfferHeaderKey]; !ok || len(v) != 1 || v[0] != "1" { 172 | return nil, fmt.Errorf("missing or invalid %s header", webTransportDraftOfferHeaderKey) 173 | } 174 | if !s.CheckOrigin(r) { 175 | return nil, errors.New("webtransport: request origin not allowed") 176 | } 177 | 178 | // Wait for SETTINGS 179 | conn := w.(http3.Hijacker).Connection() 180 | timer := time.NewTimer(s.timeout()) 181 | defer timer.Stop() 182 | select { 183 | case <-conn.ReceivedSettings(): 184 | case <-timer.C: 185 | return nil, errors.New("webtransport: didn't receive the client's SETTINGS on time") 186 | } 187 | settings := conn.Settings() 188 | if !settings.EnableDatagrams { 189 | return nil, errors.New("webtransport: missing datagram support") 190 | } 191 | 192 | w.Header().Add(webTransportDraftHeaderKey, webTransportDraftHeaderValue) 193 | w.WriteHeader(http.StatusOK) 194 | w.(http.Flusher).Flush() 195 | 196 | str := w.(http3.HTTPStreamer).HTTPStream() 197 | sessID := sessionID(str.StreamID()) 198 | return s.conns.AddSession(conn, sessID, str), nil 199 | } 200 | 201 | // copied from https://github.com/gorilla/websocket 202 | func checkSameOrigin(r *http.Request) bool { 203 | origin := r.Header.Get("Origin") 204 | if origin == "" { 205 | return true 206 | } 207 | u, err := url.Parse(origin) 208 | if err != nil { 209 | return false 210 | } 211 | return equalASCIIFold(u.Host, r.Host) 212 | } 213 | 214 | // copied from https://github.com/gorilla/websocket 215 | func equalASCIIFold(s, t string) bool { 216 | for s != "" && t != "" { 217 | sr, size := utf8.DecodeRuneInString(s) 218 | s = s[size:] 219 | tr, size := utf8.DecodeRuneInString(t) 220 | t = t[size:] 221 | if sr == tr { 222 | continue 223 | } 224 | if 'A' <= sr && sr <= 'Z' { 225 | sr = sr + 'a' - 'A' 226 | } 227 | if 'A' <= tr && tr <= 'Z' { 228 | tr = tr + 'a' - 'A' 229 | } 230 | if sr != tr { 231 | return false 232 | } 233 | } 234 | return s == t 235 | } 236 | -------------------------------------------------------------------------------- /server_test.go: -------------------------------------------------------------------------------- 1 | package webtransport_test 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "fmt" 7 | "io" 8 | "net" 9 | "net/http" 10 | "net/http/httptest" 11 | "net/url" 12 | "os" 13 | "testing" 14 | "time" 15 | 16 | "github.com/quic-go/webtransport-go" 17 | 18 | "github.com/quic-go/quic-go" 19 | "github.com/quic-go/quic-go/http3" 20 | "github.com/quic-go/quic-go/quicvarint" 21 | 22 | "github.com/stretchr/testify/require" 23 | ) 24 | 25 | const webTransportFrameType = 0x41 26 | 27 | func scaleDuration(d time.Duration) time.Duration { 28 | if os.Getenv("CI") != "" { 29 | return 5 * d 30 | } 31 | return d 32 | } 33 | 34 | func TestUpgradeFailures(t *testing.T) { 35 | var s webtransport.Server 36 | 37 | t.Run("wrong request method", func(t *testing.T) { 38 | req := httptest.NewRequest(http.MethodGet, "/webtransport", nil) 39 | _, err := s.Upgrade(httptest.NewRecorder(), req) 40 | require.EqualError(t, err, "expected CONNECT request, got GET") 41 | }) 42 | 43 | t.Run("wrong protocol", func(t *testing.T) { 44 | req := httptest.NewRequest(http.MethodConnect, "/webtransport", nil) 45 | _, err := s.Upgrade(httptest.NewRecorder(), req) 46 | require.EqualError(t, err, "unexpected protocol: HTTP/1.1") 47 | }) 48 | 49 | t.Run("missing WebTransport header", func(t *testing.T) { 50 | req := httptest.NewRequest(http.MethodConnect, "/webtransport", nil) 51 | req.Proto = "webtransport" 52 | _, err := s.Upgrade(httptest.NewRecorder(), req) 53 | require.EqualError(t, err, "missing or invalid Sec-Webtransport-Http3-Draft02 header") 54 | }) 55 | } 56 | 57 | func newWebTransportRequest(t *testing.T, addr string) *http.Request { 58 | t.Helper() 59 | u, err := url.Parse(addr) 60 | require.NoError(t, err) 61 | hdr := make(http.Header) 62 | hdr.Add("Sec-Webtransport-Http3-Draft02", "1") 63 | return &http.Request{ 64 | Method: http.MethodConnect, 65 | Header: hdr, 66 | Proto: "webtransport", 67 | Host: u.Host, 68 | URL: u, 69 | } 70 | } 71 | 72 | func createStreamAndWrite(t *testing.T, conn quic.Connection, sessionID uint64, data []byte) quic.Stream { 73 | t.Helper() 74 | str, err := conn.OpenStream() 75 | require.NoError(t, err) 76 | var buf []byte 77 | buf = quicvarint.Append(buf, webTransportFrameType) 78 | buf = quicvarint.Append(buf, sessionID) // stream ID of the stream used to establish the WebTransport session. 79 | buf = append(buf, data...) 80 | _, err = str.Write(buf) 81 | require.NoError(t, err) 82 | require.NoError(t, str.Close()) 83 | return str 84 | } 85 | 86 | func TestServerReorderedUpgradeRequest(t *testing.T) { 87 | s := webtransport.Server{ 88 | H3: http3.Server{TLSConfig: tlsConf}, 89 | } 90 | defer s.Close() 91 | connChan := make(chan *webtransport.Session) 92 | addHandler(t, &s, func(c *webtransport.Session) { 93 | connChan <- c 94 | }) 95 | 96 | udpConn, err := net.ListenUDP("udp", nil) 97 | require.NoError(t, err) 98 | port := udpConn.LocalAddr().(*net.UDPAddr).Port 99 | go s.Serve(udpConn) 100 | 101 | cconn, err := quic.DialAddr( 102 | context.Background(), 103 | fmt.Sprintf("localhost:%d", port), 104 | &tls.Config{RootCAs: certPool, NextProtos: []string{http3.NextProtoH3}}, 105 | &quic.Config{EnableDatagrams: true}, 106 | ) 107 | require.NoError(t, err) 108 | // Open a new stream for a WebTransport session we'll establish later. Stream ID: 0. 109 | createStreamAndWrite(t, cconn, 4, []byte("foobar")) 110 | tr := &http3.Transport{EnableDatagrams: true} 111 | conn := tr.NewClientConn(cconn) 112 | 113 | // make sure this request actually arrives first 114 | time.Sleep(scaleDuration(50 * time.Millisecond)) 115 | 116 | // Create a new WebTransport session. Stream ID: 4. 117 | str, err := conn.OpenRequestStream(context.Background()) 118 | require.NoError(t, err) 119 | require.NoError(t, str.SendRequestHeader(newWebTransportRequest(t, fmt.Sprintf("https://localhost:%d/webtransport", port)))) 120 | rsp, err := str.ReadResponse() 121 | require.NoError(t, err) 122 | require.Equal(t, http.StatusOK, rsp.StatusCode) 123 | sconn := <-connChan 124 | defer sconn.CloseWithError(0, "") 125 | ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) 126 | defer cancel() 127 | sstr, err := sconn.AcceptStream(ctx) 128 | require.NoError(t, err) 129 | data, err := io.ReadAll(sstr) 130 | require.NoError(t, err) 131 | require.Equal(t, []byte("foobar"), data) 132 | 133 | // Establish another stream and make sure it's accepted now. 134 | createStreamAndWrite(t, cconn, 4, []byte("raboof")) 135 | ctx, cancel = context.WithTimeout(context.Background(), 200*time.Millisecond) 136 | defer cancel() 137 | sstr, err = sconn.AcceptStream(ctx) 138 | require.NoError(t, err) 139 | data, err = io.ReadAll(sstr) 140 | require.NoError(t, err) 141 | require.Equal(t, []byte("raboof"), data) 142 | } 143 | 144 | func TestServerReorderedUpgradeRequestTimeout(t *testing.T) { 145 | timeout := scaleDuration(100 * time.Millisecond) 146 | s := webtransport.Server{ 147 | H3: http3.Server{TLSConfig: tlsConf, EnableDatagrams: true}, 148 | ReorderingTimeout: timeout, 149 | } 150 | defer s.Close() 151 | connChan := make(chan *webtransport.Session) 152 | addHandler(t, &s, func(c *webtransport.Session) { 153 | connChan <- c 154 | }) 155 | 156 | udpConn, err := net.ListenUDP("udp", nil) 157 | require.NoError(t, err) 158 | port := udpConn.LocalAddr().(*net.UDPAddr).Port 159 | go s.Serve(udpConn) 160 | 161 | cconn, err := quic.DialAddr( 162 | context.Background(), 163 | fmt.Sprintf("localhost:%d", port), 164 | &tls.Config{RootCAs: certPool, NextProtos: []string{http3.NextProtoH3}}, 165 | &quic.Config{EnableDatagrams: true}, 166 | ) 167 | require.NoError(t, err) 168 | 169 | // Open a new stream for a WebTransport session we'll establish later. Stream ID: 0. 170 | str := createStreamAndWrite(t, cconn, 4, []byte("foobar")) 171 | 172 | time.Sleep(2 * timeout) 173 | 174 | tr := &http3.Transport{EnableDatagrams: true} 175 | conn := tr.NewClientConn(cconn) 176 | 177 | // Reordering was too long. The stream should now have been reset by the server. 178 | _, err = str.Read([]byte{0}) 179 | var streamErr *quic.StreamError 180 | require.ErrorAs(t, err, &streamErr) 181 | require.Equal(t, webtransport.WebTransportBufferedStreamRejectedErrorCode, streamErr.ErrorCode) 182 | 183 | // Now establish the session. Make sure we don't accept the stream. 184 | requestStr, err := conn.OpenRequestStream(context.Background()) 185 | require.NoError(t, err) 186 | require.NoError(t, requestStr.SendRequestHeader(newWebTransportRequest(t, fmt.Sprintf("https://localhost:%d/webtransport", port)))) 187 | rsp, err := requestStr.ReadResponse() 188 | require.NoError(t, err) 189 | require.Equal(t, http.StatusOK, rsp.StatusCode) 190 | sconn := <-connChan 191 | defer sconn.CloseWithError(0, "") 192 | ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) 193 | defer cancel() 194 | _, err = sconn.AcceptStream(ctx) 195 | require.ErrorIs(t, err, context.DeadlineExceeded) 196 | 197 | // Establish another stream and make sure it's accepted now. 198 | createStreamAndWrite(t, cconn, 4, []byte("raboof")) 199 | ctx, cancel = context.WithTimeout(context.Background(), 200*time.Millisecond) 200 | defer cancel() 201 | sstr, err := sconn.AcceptStream(ctx) 202 | require.NoError(t, err) 203 | data, err := io.ReadAll(sstr) 204 | require.NoError(t, err) 205 | require.Equal(t, []byte("raboof"), data) 206 | } 207 | 208 | func TestServerReorderedMultipleStreams(t *testing.T) { 209 | timeout := scaleDuration(150 * time.Millisecond) 210 | s := webtransport.Server{ 211 | H3: http3.Server{TLSConfig: tlsConf, EnableDatagrams: true}, 212 | ReorderingTimeout: timeout, 213 | } 214 | defer s.Close() 215 | connChan := make(chan *webtransport.Session) 216 | addHandler(t, &s, func(c *webtransport.Session) { 217 | connChan <- c 218 | }) 219 | 220 | udpConn, err := net.ListenUDP("udp", nil) 221 | require.NoError(t, err) 222 | port := udpConn.LocalAddr().(*net.UDPAddr).Port 223 | go s.Serve(udpConn) 224 | 225 | cconn, err := quic.DialAddr( 226 | context.Background(), 227 | fmt.Sprintf("localhost:%d", port), 228 | &tls.Config{RootCAs: certPool, NextProtos: []string{http3.NextProtoH3}}, 229 | &quic.Config{EnableDatagrams: true}, 230 | ) 231 | require.NoError(t, err) 232 | start := time.Now() 233 | // Open a new stream for a WebTransport session we'll establish later. Stream ID: 0. 234 | str1 := createStreamAndWrite(t, cconn, 8, []byte("foobar")) 235 | 236 | // After a while, open another stream. 237 | time.Sleep(timeout / 2) 238 | // Open a new stream for a WebTransport session we'll establish later. Stream ID: 4. 239 | createStreamAndWrite(t, cconn, 8, []byte("raboof")) 240 | 241 | // Reordering was too long. The stream should now have been reset by the server. 242 | _, err = str1.Read([]byte{0}) 243 | var streamErr *quic.StreamError 244 | require.ErrorAs(t, err, &streamErr) 245 | require.Equal(t, webtransport.WebTransportBufferedStreamRejectedErrorCode, streamErr.ErrorCode) 246 | took := time.Since(start) 247 | require.GreaterOrEqual(t, took, timeout) 248 | require.Less(t, took, timeout*5/4) 249 | 250 | tr := &http3.Transport{EnableDatagrams: true} 251 | conn := tr.NewClientConn(cconn) 252 | // Now establish the session. Make sure we don't accept the stream. 253 | requestStr, err := conn.OpenRequestStream(context.Background()) 254 | require.NoError(t, err) 255 | require.NoError(t, requestStr.SendRequestHeader(newWebTransportRequest(t, fmt.Sprintf("https://localhost:%d/webtransport", port)))) 256 | rsp, err := requestStr.ReadResponse() 257 | require.NoError(t, err) 258 | require.Equal(t, http.StatusOK, rsp.StatusCode) 259 | sconn := <-connChan 260 | defer sconn.CloseWithError(0, "") 261 | ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) 262 | defer cancel() 263 | sstr, err := sconn.AcceptStream(ctx) 264 | require.NoError(t, err) 265 | data, err := io.ReadAll(sstr) 266 | require.NoError(t, err) 267 | require.Equal(t, []byte("raboof"), data) 268 | } 269 | 270 | func TestServerSettingsCheck(t *testing.T) { 271 | timeout := scaleDuration(150 * time.Millisecond) 272 | s := webtransport.Server{ 273 | H3: http3.Server{TLSConfig: tlsConf, EnableDatagrams: true}, 274 | ReorderingTimeout: timeout, 275 | } 276 | errChan := make(chan error, 1) 277 | mux := http.NewServeMux() 278 | mux.HandleFunc("/webtransport", func(w http.ResponseWriter, r *http.Request) { 279 | _, err := s.Upgrade(w, r) 280 | w.WriteHeader(http.StatusNotImplemented) 281 | errChan <- err 282 | }) 283 | s.H3.Handler = mux 284 | udpConn, err := net.ListenUDP("udp", nil) 285 | require.NoError(t, err) 286 | port := udpConn.LocalAddr().(*net.UDPAddr).Port 287 | go s.Serve(udpConn) 288 | 289 | cconn, err := quic.DialAddr( 290 | context.Background(), 291 | fmt.Sprintf("localhost:%d", port), 292 | &tls.Config{RootCAs: certPool, NextProtos: []string{http3.NextProtoH3}}, 293 | &quic.Config{EnableDatagrams: true}, 294 | ) 295 | require.NoError(t, err) 296 | tr := &http3.Transport{EnableDatagrams: false} 297 | conn := tr.NewClientConn(cconn) 298 | requestStr, err := conn.OpenRequestStream(context.Background()) 299 | require.NoError(t, err) 300 | require.NoError(t, requestStr.SendRequestHeader(newWebTransportRequest(t, fmt.Sprintf("https://localhost:%d/webtransport", port)))) 301 | rsp, err := requestStr.ReadResponse() 302 | require.NoError(t, err) 303 | require.Equal(t, http.StatusNotImplemented, rsp.StatusCode) 304 | 305 | require.ErrorContains(t, <-errChan, "webtransport: missing datagram support") 306 | } 307 | 308 | func TestImmediateClose(t *testing.T) { 309 | s := webtransport.Server{H3: http3.Server{}} 310 | require.NoError(t, s.Close()) 311 | } 312 | -------------------------------------------------------------------------------- /session.go: -------------------------------------------------------------------------------- 1 | package webtransport 2 | 3 | import ( 4 | "context" 5 | "encoding/binary" 6 | "errors" 7 | "io" 8 | "math/rand" 9 | "net" 10 | "sync" 11 | 12 | "github.com/quic-go/quic-go" 13 | "github.com/quic-go/quic-go/http3" 14 | "github.com/quic-go/quic-go/quicvarint" 15 | ) 16 | 17 | // sessionID is the WebTransport Session ID 18 | type sessionID uint64 19 | 20 | const closeWebtransportSessionCapsuleType http3.CapsuleType = 0x2843 21 | 22 | type acceptQueue[T any] struct { 23 | mx sync.Mutex 24 | // The channel is used to notify consumers (via Chan) about new incoming items. 25 | // Needs to be buffered to preserve the notification if an item is enqueued 26 | // between a call to Next and to Chan. 27 | c chan struct{} 28 | // Contains all the streams waiting to be accepted. 29 | // There's no explicit limit to the length of the queue, but it is implicitly 30 | // limited by the stream flow control provided by QUIC. 31 | queue []T 32 | } 33 | 34 | func newAcceptQueue[T any]() *acceptQueue[T] { 35 | return &acceptQueue[T]{c: make(chan struct{}, 1)} 36 | } 37 | 38 | func (q *acceptQueue[T]) Add(str T) { 39 | q.mx.Lock() 40 | q.queue = append(q.queue, str) 41 | q.mx.Unlock() 42 | 43 | select { 44 | case q.c <- struct{}{}: 45 | default: 46 | } 47 | } 48 | 49 | func (q *acceptQueue[T]) Next() T { 50 | q.mx.Lock() 51 | defer q.mx.Unlock() 52 | 53 | if len(q.queue) == 0 { 54 | return *new(T) 55 | } 56 | str := q.queue[0] 57 | q.queue = q.queue[1:] 58 | return str 59 | } 60 | 61 | func (q *acceptQueue[T]) Chan() <-chan struct{} { return q.c } 62 | 63 | type Session struct { 64 | sessionID sessionID 65 | qconn http3.Connection 66 | requestStr http3.Stream 67 | 68 | streamHdr []byte 69 | uniStreamHdr []byte 70 | 71 | ctx context.Context 72 | closeMx sync.Mutex 73 | closeErr error // not nil once the session is closed 74 | // streamCtxs holds all the context.CancelFuncs of calls to Open{Uni}StreamSync calls currently active. 75 | // When the session is closed, this allows us to cancel all these contexts and make those calls return. 76 | streamCtxs map[int]context.CancelFunc 77 | 78 | bidiAcceptQueue acceptQueue[Stream] 79 | uniAcceptQueue acceptQueue[ReceiveStream] 80 | 81 | // TODO: garbage collect streams from when they are closed 82 | streams streamsMap 83 | } 84 | 85 | func newSession(sessionID sessionID, qconn http3.Connection, requestStr http3.Stream) *Session { 86 | tracingID := qconn.Context().Value(quic.ConnectionTracingKey).(quic.ConnectionTracingID) 87 | ctx, ctxCancel := context.WithCancel(context.WithValue(context.Background(), quic.ConnectionTracingKey, tracingID)) 88 | c := &Session{ 89 | sessionID: sessionID, 90 | qconn: qconn, 91 | requestStr: requestStr, 92 | ctx: ctx, 93 | streamCtxs: make(map[int]context.CancelFunc), 94 | bidiAcceptQueue: *newAcceptQueue[Stream](), 95 | uniAcceptQueue: *newAcceptQueue[ReceiveStream](), 96 | streams: *newStreamsMap(), 97 | } 98 | // precompute the headers for unidirectional streams 99 | c.uniStreamHdr = make([]byte, 0, 2+quicvarint.Len(uint64(c.sessionID))) 100 | c.uniStreamHdr = quicvarint.Append(c.uniStreamHdr, webTransportUniStreamType) 101 | c.uniStreamHdr = quicvarint.Append(c.uniStreamHdr, uint64(c.sessionID)) 102 | // precompute the headers for bidirectional streams 103 | c.streamHdr = make([]byte, 0, 2+quicvarint.Len(uint64(c.sessionID))) 104 | c.streamHdr = quicvarint.Append(c.streamHdr, webTransportFrameType) 105 | c.streamHdr = quicvarint.Append(c.streamHdr, uint64(c.sessionID)) 106 | 107 | go func() { 108 | defer ctxCancel() 109 | c.handleConn() 110 | }() 111 | return c 112 | } 113 | 114 | func (s *Session) handleConn() { 115 | var closeErr *SessionError 116 | err := s.parseNextCapsule() 117 | if !errors.As(err, &closeErr) { 118 | closeErr = &SessionError{Remote: true} 119 | } 120 | 121 | s.closeMx.Lock() 122 | defer s.closeMx.Unlock() 123 | // If we closed the connection, the closeErr will be set in Close. 124 | if s.closeErr == nil { 125 | s.closeErr = closeErr 126 | } 127 | for _, cancel := range s.streamCtxs { 128 | cancel() 129 | } 130 | s.streams.CloseSession() 131 | } 132 | 133 | // parseNextCapsule parses the next Capsule sent on the request stream. 134 | // It returns a SessionError, if the capsule received is a CLOSE_WEBTRANSPORT_SESSION Capsule. 135 | func (s *Session) parseNextCapsule() error { 136 | for { 137 | // TODO: enforce max size 138 | typ, r, err := http3.ParseCapsule(quicvarint.NewReader(s.requestStr)) 139 | if err != nil { 140 | return err 141 | } 142 | switch typ { 143 | case closeWebtransportSessionCapsuleType: 144 | b := make([]byte, 4) 145 | if _, err := io.ReadFull(r, b); err != nil { 146 | return err 147 | } 148 | appErrCode := binary.BigEndian.Uint32(b) 149 | appErrMsg, err := io.ReadAll(r) 150 | if err != nil { 151 | return err 152 | } 153 | return &SessionError{ 154 | Remote: true, 155 | ErrorCode: SessionErrorCode(appErrCode), 156 | Message: string(appErrMsg), 157 | } 158 | default: 159 | // unknown capsule, skip it 160 | if _, err := io.ReadAll(r); err != nil { 161 | return err 162 | } 163 | } 164 | } 165 | } 166 | 167 | func (s *Session) addStream(qstr quic.Stream, addStreamHeader bool) Stream { 168 | var hdr []byte 169 | if addStreamHeader { 170 | hdr = s.streamHdr 171 | } 172 | str := newStream(qstr, hdr, func() { s.streams.RemoveStream(qstr.StreamID()) }) 173 | s.streams.AddStream(qstr.StreamID(), str.closeWithSession) 174 | return str 175 | } 176 | 177 | func (s *Session) addReceiveStream(qstr quic.ReceiveStream) ReceiveStream { 178 | str := newReceiveStream(qstr, func() { s.streams.RemoveStream(qstr.StreamID()) }) 179 | s.streams.AddStream(qstr.StreamID(), func() { 180 | str.closeWithSession() 181 | }) 182 | return str 183 | } 184 | 185 | func (s *Session) addSendStream(qstr quic.SendStream) SendStream { 186 | str := newSendStream(qstr, s.uniStreamHdr, func() { s.streams.RemoveStream(qstr.StreamID()) }) 187 | s.streams.AddStream(qstr.StreamID(), str.closeWithSession) 188 | return str 189 | } 190 | 191 | // addIncomingStream adds a bidirectional stream that the remote peer opened 192 | func (s *Session) addIncomingStream(qstr quic.Stream) { 193 | s.closeMx.Lock() 194 | closeErr := s.closeErr 195 | if closeErr != nil { 196 | s.closeMx.Unlock() 197 | qstr.CancelRead(sessionCloseErrorCode) 198 | qstr.CancelWrite(sessionCloseErrorCode) 199 | return 200 | } 201 | str := s.addStream(qstr, false) 202 | s.closeMx.Unlock() 203 | 204 | s.bidiAcceptQueue.Add(str) 205 | } 206 | 207 | // addIncomingUniStream adds a unidirectional stream that the remote peer opened 208 | func (s *Session) addIncomingUniStream(qstr quic.ReceiveStream) { 209 | s.closeMx.Lock() 210 | closeErr := s.closeErr 211 | if closeErr != nil { 212 | s.closeMx.Unlock() 213 | qstr.CancelRead(sessionCloseErrorCode) 214 | return 215 | } 216 | str := s.addReceiveStream(qstr) 217 | s.closeMx.Unlock() 218 | 219 | s.uniAcceptQueue.Add(str) 220 | } 221 | 222 | // Context returns a context that is closed when the session is closed. 223 | func (s *Session) Context() context.Context { 224 | return s.ctx 225 | } 226 | 227 | func (s *Session) AcceptStream(ctx context.Context) (Stream, error) { 228 | s.closeMx.Lock() 229 | closeErr := s.closeErr 230 | s.closeMx.Unlock() 231 | if closeErr != nil { 232 | return nil, closeErr 233 | } 234 | 235 | for { 236 | // If there's a stream in the accept queue, return it immediately. 237 | if str := s.bidiAcceptQueue.Next(); str != nil { 238 | return str, nil 239 | } 240 | // No stream in the accept queue. Wait until we accept one. 241 | select { 242 | case <-s.ctx.Done(): 243 | return nil, s.closeErr 244 | case <-ctx.Done(): 245 | return nil, ctx.Err() 246 | case <-s.bidiAcceptQueue.Chan(): 247 | } 248 | } 249 | } 250 | 251 | func (s *Session) AcceptUniStream(ctx context.Context) (ReceiveStream, error) { 252 | s.closeMx.Lock() 253 | closeErr := s.closeErr 254 | s.closeMx.Unlock() 255 | if closeErr != nil { 256 | return nil, s.closeErr 257 | } 258 | 259 | for { 260 | // If there's a stream in the accept queue, return it immediately. 261 | if str := s.uniAcceptQueue.Next(); str != nil { 262 | return str, nil 263 | } 264 | // No stream in the accept queue. Wait until we accept one. 265 | select { 266 | case <-s.ctx.Done(): 267 | return nil, s.closeErr 268 | case <-ctx.Done(): 269 | return nil, ctx.Err() 270 | case <-s.uniAcceptQueue.Chan(): 271 | } 272 | } 273 | } 274 | 275 | func (s *Session) OpenStream() (Stream, error) { 276 | s.closeMx.Lock() 277 | defer s.closeMx.Unlock() 278 | 279 | if s.closeErr != nil { 280 | return nil, s.closeErr 281 | } 282 | 283 | qstr, err := s.qconn.OpenStream() 284 | if err != nil { 285 | return nil, err 286 | } 287 | return s.addStream(qstr, true), nil 288 | } 289 | 290 | func (s *Session) addStreamCtxCancel(cancel context.CancelFunc) (id int) { 291 | rand: 292 | id = rand.Int() 293 | if _, ok := s.streamCtxs[id]; ok { 294 | goto rand 295 | } 296 | s.streamCtxs[id] = cancel 297 | return id 298 | } 299 | 300 | func (s *Session) OpenStreamSync(ctx context.Context) (Stream, error) { 301 | s.closeMx.Lock() 302 | if s.closeErr != nil { 303 | s.closeMx.Unlock() 304 | return nil, s.closeErr 305 | } 306 | ctx, cancel := context.WithCancel(ctx) 307 | id := s.addStreamCtxCancel(cancel) 308 | s.closeMx.Unlock() 309 | 310 | qstr, err := s.qconn.OpenStreamSync(ctx) 311 | if err != nil { 312 | if s.closeErr != nil { 313 | return nil, s.closeErr 314 | } 315 | return nil, err 316 | } 317 | 318 | s.closeMx.Lock() 319 | defer s.closeMx.Unlock() 320 | delete(s.streamCtxs, id) 321 | // Some time might have passed. Check if the session is still alive 322 | if s.closeErr != nil { 323 | qstr.CancelWrite(sessionCloseErrorCode) 324 | qstr.CancelRead(sessionCloseErrorCode) 325 | return nil, s.closeErr 326 | } 327 | return s.addStream(qstr, true), nil 328 | } 329 | 330 | func (s *Session) OpenUniStream() (SendStream, error) { 331 | s.closeMx.Lock() 332 | defer s.closeMx.Unlock() 333 | 334 | if s.closeErr != nil { 335 | return nil, s.closeErr 336 | } 337 | qstr, err := s.qconn.OpenUniStream() 338 | if err != nil { 339 | return nil, err 340 | } 341 | return s.addSendStream(qstr), nil 342 | } 343 | 344 | func (s *Session) OpenUniStreamSync(ctx context.Context) (str SendStream, err error) { 345 | s.closeMx.Lock() 346 | if s.closeErr != nil { 347 | s.closeMx.Unlock() 348 | return nil, s.closeErr 349 | } 350 | ctx, cancel := context.WithCancel(ctx) 351 | id := s.addStreamCtxCancel(cancel) 352 | s.closeMx.Unlock() 353 | 354 | qstr, err := s.qconn.OpenUniStreamSync(ctx) 355 | if err != nil { 356 | if s.closeErr != nil { 357 | return nil, s.closeErr 358 | } 359 | return nil, err 360 | } 361 | 362 | s.closeMx.Lock() 363 | defer s.closeMx.Unlock() 364 | delete(s.streamCtxs, id) 365 | // Some time might have passed. Check if the session is still alive 366 | if s.closeErr != nil { 367 | qstr.CancelWrite(sessionCloseErrorCode) 368 | return nil, s.closeErr 369 | } 370 | return s.addSendStream(qstr), nil 371 | } 372 | 373 | func (s *Session) LocalAddr() net.Addr { 374 | return s.qconn.LocalAddr() 375 | } 376 | 377 | func (s *Session) RemoteAddr() net.Addr { 378 | return s.qconn.RemoteAddr() 379 | } 380 | 381 | func (s *Session) CloseWithError(code SessionErrorCode, msg string) error { 382 | first, err := s.closeWithError(code, msg) 383 | if err != nil || !first { 384 | return err 385 | } 386 | 387 | s.requestStr.CancelRead(1337) 388 | err = s.requestStr.Close() 389 | <-s.ctx.Done() 390 | return err 391 | } 392 | 393 | func (s *Session) SendDatagram(b []byte) error { 394 | return s.requestStr.SendDatagram(b) 395 | } 396 | 397 | func (s *Session) ReceiveDatagram(ctx context.Context) ([]byte, error) { 398 | return s.requestStr.ReceiveDatagram(ctx) 399 | } 400 | 401 | func (s *Session) closeWithError(code SessionErrorCode, msg string) (bool /* first call to close session */, error) { 402 | s.closeMx.Lock() 403 | defer s.closeMx.Unlock() 404 | // Duplicate call, or the remote already closed this session. 405 | if s.closeErr != nil { 406 | return false, nil 407 | } 408 | s.closeErr = &SessionError{ 409 | ErrorCode: code, 410 | Message: msg, 411 | } 412 | 413 | b := make([]byte, 4, 4+len(msg)) 414 | binary.BigEndian.PutUint32(b, uint32(code)) 415 | b = append(b, []byte(msg)...) 416 | 417 | return true, http3.WriteCapsule( 418 | quicvarint.NewWriter(s.requestStr), 419 | closeWebtransportSessionCapsuleType, 420 | b, 421 | ) 422 | } 423 | 424 | func (s *Session) ConnectionState() quic.ConnectionState { 425 | return s.qconn.ConnectionState() 426 | } 427 | -------------------------------------------------------------------------------- /session_manager.go: -------------------------------------------------------------------------------- 1 | package webtransport 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "github.com/quic-go/quic-go" 9 | "github.com/quic-go/quic-go/http3" 10 | "github.com/quic-go/quic-go/quicvarint" 11 | ) 12 | 13 | // session is the map value in the conns map 14 | type session struct { 15 | created chan struct{} // is closed once the session map has been initialized 16 | counter int // how many streams are waiting for this session to be established 17 | conn *Session 18 | } 19 | 20 | type sessionManager struct { 21 | refCount sync.WaitGroup 22 | ctx context.Context 23 | ctxCancel context.CancelFunc 24 | 25 | timeout time.Duration 26 | 27 | mx sync.Mutex 28 | conns map[quic.ConnectionTracingID]map[sessionID]*session 29 | } 30 | 31 | func newSessionManager(timeout time.Duration) *sessionManager { 32 | m := &sessionManager{ 33 | timeout: timeout, 34 | conns: make(map[quic.ConnectionTracingID]map[sessionID]*session), 35 | } 36 | m.ctx, m.ctxCancel = context.WithCancel(context.Background()) 37 | return m 38 | } 39 | 40 | // AddStream adds a new bidirectional stream to a WebTransport session. 41 | // If the WebTransport session has not yet been established, 42 | // it starts a new go routine and waits for establishment of the session. 43 | // If that takes longer than timeout, the stream is reset. 44 | func (m *sessionManager) AddStream(connTracingID quic.ConnectionTracingID, str quic.Stream, id sessionID) { 45 | sess, isExisting := m.getOrCreateSession(connTracingID, id) 46 | if isExisting { 47 | sess.conn.addIncomingStream(str) 48 | return 49 | } 50 | 51 | m.refCount.Add(1) 52 | go func() { 53 | defer m.refCount.Done() 54 | m.handleStream(str, sess) 55 | 56 | m.mx.Lock() 57 | defer m.mx.Unlock() 58 | 59 | sess.counter-- 60 | // Once no more streams are waiting for this session to be established, 61 | // and this session is still outstanding, delete it from the map. 62 | if sess.counter == 0 && sess.conn == nil { 63 | m.maybeDelete(connTracingID, id) 64 | } 65 | }() 66 | } 67 | 68 | func (m *sessionManager) maybeDelete(connTracingID quic.ConnectionTracingID, id sessionID) { 69 | sessions, ok := m.conns[connTracingID] 70 | if !ok { // should never happen 71 | return 72 | } 73 | delete(sessions, id) 74 | if len(sessions) == 0 { 75 | delete(m.conns, connTracingID) 76 | } 77 | } 78 | 79 | // AddUniStream adds a new unidirectional stream to a WebTransport session. 80 | // If the WebTransport session has not yet been established, 81 | // it starts a new go routine and waits for establishment of the session. 82 | // If that takes longer than timeout, the stream is reset. 83 | func (m *sessionManager) AddUniStream(connTracingID quic.ConnectionTracingID, str quic.ReceiveStream) { 84 | idv, err := quicvarint.Read(quicvarint.NewReader(str)) 85 | if err != nil { 86 | str.CancelRead(1337) 87 | } 88 | id := sessionID(idv) 89 | 90 | sess, isExisting := m.getOrCreateSession(connTracingID, id) 91 | if isExisting { 92 | sess.conn.addIncomingUniStream(str) 93 | return 94 | } 95 | 96 | m.refCount.Add(1) 97 | go func() { 98 | defer m.refCount.Done() 99 | m.handleUniStream(str, sess) 100 | 101 | m.mx.Lock() 102 | defer m.mx.Unlock() 103 | 104 | sess.counter-- 105 | // Once no more streams are waiting for this session to be established, 106 | // and this session is still outstanding, delete it from the map. 107 | if sess.counter == 0 && sess.conn == nil { 108 | m.maybeDelete(connTracingID, id) 109 | } 110 | }() 111 | } 112 | 113 | func (m *sessionManager) getOrCreateSession(connTracingID quic.ConnectionTracingID, id sessionID) (sess *session, existed bool) { 114 | m.mx.Lock() 115 | defer m.mx.Unlock() 116 | 117 | sessions, ok := m.conns[connTracingID] 118 | if !ok { 119 | sessions = make(map[sessionID]*session) 120 | m.conns[connTracingID] = sessions 121 | } 122 | 123 | sess, ok = sessions[id] 124 | if ok && sess.conn != nil { 125 | return sess, true 126 | } 127 | if !ok { 128 | sess = &session{created: make(chan struct{})} 129 | sessions[id] = sess 130 | } 131 | sess.counter++ 132 | return sess, false 133 | } 134 | 135 | func (m *sessionManager) handleStream(str quic.Stream, sess *session) { 136 | t := time.NewTimer(m.timeout) 137 | defer t.Stop() 138 | 139 | // When multiple streams are waiting for the same session to be established, 140 | // the timeout is calculated for every stream separately. 141 | select { 142 | case <-sess.created: 143 | sess.conn.addIncomingStream(str) 144 | case <-t.C: 145 | str.CancelRead(WebTransportBufferedStreamRejectedErrorCode) 146 | str.CancelWrite(WebTransportBufferedStreamRejectedErrorCode) 147 | case <-m.ctx.Done(): 148 | } 149 | } 150 | 151 | func (m *sessionManager) handleUniStream(str quic.ReceiveStream, sess *session) { 152 | t := time.NewTimer(m.timeout) 153 | defer t.Stop() 154 | 155 | // When multiple streams are waiting for the same session to be established, 156 | // the timeout is calculated for every stream separately. 157 | select { 158 | case <-sess.created: 159 | sess.conn.addIncomingUniStream(str) 160 | case <-t.C: 161 | str.CancelRead(WebTransportBufferedStreamRejectedErrorCode) 162 | case <-m.ctx.Done(): 163 | } 164 | } 165 | 166 | // AddSession adds a new WebTransport session. 167 | func (m *sessionManager) AddSession(qconn http3.Connection, id sessionID, requestStr http3.Stream) *Session { 168 | conn := newSession(id, qconn, requestStr) 169 | connTracingID := qconn.Context().Value(quic.ConnectionTracingKey).(quic.ConnectionTracingID) 170 | 171 | m.mx.Lock() 172 | defer m.mx.Unlock() 173 | 174 | sessions, ok := m.conns[connTracingID] 175 | if !ok { 176 | sessions = make(map[sessionID]*session) 177 | m.conns[connTracingID] = sessions 178 | } 179 | if sess, ok := sessions[id]; ok { 180 | // We might already have an entry of this session. 181 | // This can happen when we receive a stream for this WebTransport session before we complete the HTTP request 182 | // that establishes the session. 183 | sess.conn = conn 184 | close(sess.created) 185 | return conn 186 | } 187 | c := make(chan struct{}) 188 | close(c) 189 | sessions[id] = &session{created: c, conn: conn} 190 | return conn 191 | } 192 | 193 | func (m *sessionManager) Close() { 194 | m.ctxCancel() 195 | m.refCount.Wait() 196 | } 197 | -------------------------------------------------------------------------------- /session_test.go: -------------------------------------------------------------------------------- 1 | package webtransport 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "testing" 7 | "time" 8 | 9 | "github.com/quic-go/quic-go" 10 | "github.com/quic-go/quic-go/http3" 11 | 12 | "github.com/stretchr/testify/require" 13 | "go.uber.org/mock/gomock" 14 | ) 15 | 16 | //go:generate sh -c "go run go.uber.org/mock/mockgen -package webtransport -destination mock_connection_test.go github.com/quic-go/quic-go/http3 Connection && cat mock_connection_test.go | sed s@qerr\\.ApplicationErrorCode@quic.ApplicationErrorCode@g > tmp.go && mv tmp.go mock_connection_test.go && goimports -w mock_connection_test.go" 17 | //go:generate sh -c "go run go.uber.org/mock/mockgen -package webtransport -destination mock_stream_test.go github.com/quic-go/quic-go/http3 Stream && cat mock_stream_test.go | sed s@protocol\\.StreamID@quic.StreamID@g | sed s@qerr\\.StreamErrorCode@quic.StreamErrorCode@g > tmp.go && mv tmp.go mock_stream_test.go && goimports -w mock_stream_test.go" 18 | 19 | type mockRequestStream struct { 20 | *MockStream 21 | c chan struct{} 22 | } 23 | 24 | func newMockRequestStream(ctrl *gomock.Controller) http3.Stream { 25 | str := NewMockStream(ctrl) 26 | str.EXPECT().Close() 27 | str.EXPECT().CancelRead(gomock.Any()) 28 | return &mockRequestStream{MockStream: str, c: make(chan struct{})} 29 | } 30 | 31 | var _ io.ReadWriteCloser = &mockRequestStream{} 32 | 33 | func (s *mockRequestStream) Close() error { 34 | s.MockStream.Close() 35 | close(s.c) 36 | return nil 37 | } 38 | 39 | func (s *mockRequestStream) Read(b []byte) (int, error) { <-s.c; return 0, io.EOF } 40 | func (s *mockRequestStream) Write(b []byte) (int, error) { return len(b), nil } 41 | 42 | func TestCloseStreamsOnClose(t *testing.T) { 43 | ctrl := gomock.NewController(t) 44 | 45 | mockSess := NewMockConnection(ctrl) 46 | mockSess.EXPECT().Context().Return(context.WithValue(context.Background(), quic.ConnectionTracingKey, quic.ConnectionTracingID(1337))) 47 | sess := newSession(42, mockSess, newMockRequestStream(ctrl)) 48 | 49 | str := NewMockStream(ctrl) 50 | str.EXPECT().StreamID().Return(quic.StreamID(4)).AnyTimes() 51 | mockSess.EXPECT().OpenStream().Return(str, nil) 52 | _, err := sess.OpenStream() 53 | require.NoError(t, err) 54 | ustr := NewMockStream(ctrl) 55 | ustr.EXPECT().StreamID().Return(quic.StreamID(5)).AnyTimes() 56 | mockSess.EXPECT().OpenUniStream().Return(ustr, nil) 57 | _, err = sess.OpenUniStream() 58 | require.NoError(t, err) 59 | 60 | str.EXPECT().CancelRead(sessionCloseErrorCode) 61 | str.EXPECT().CancelWrite(sessionCloseErrorCode) 62 | ustr.EXPECT().CancelWrite(sessionCloseErrorCode) 63 | require.NoError(t, sess.CloseWithError(0, "")) 64 | } 65 | 66 | func TestOpenStreamSyncCancel(t *testing.T) { 67 | ctrl := gomock.NewController(t) 68 | defer ctrl.Finish() 69 | 70 | mockSess := NewMockConnection(ctrl) 71 | mockSess.EXPECT().Context().Return(context.WithValue(context.Background(), quic.ConnectionTracingKey, quic.ConnectionTracingID(1337))) 72 | sess := newSession(42, mockSess, newMockRequestStream(ctrl)) 73 | defer sess.CloseWithError(0, "") 74 | 75 | str := NewMockStream(ctrl) 76 | str.EXPECT().StreamID().Return(quic.StreamID(4)).AnyTimes() 77 | mockSess.EXPECT().OpenStreamSync(gomock.Any()).DoAndReturn(func(ctx context.Context) (quic.Stream, error) { 78 | <-ctx.Done() 79 | return nil, ctx.Err() 80 | }) 81 | 82 | ctx, cancel := context.WithCancel(context.Background()) 83 | errChan := make(chan error) 84 | go func() { 85 | str, err := sess.OpenStreamSync(ctx) 86 | require.Nil(t, str) 87 | errChan <- err 88 | }() 89 | time.Sleep(50 * time.Millisecond) 90 | cancel() 91 | 92 | select { 93 | case <-time.After(500 * time.Millisecond): 94 | t.Fatal("timeout") 95 | case err := <-errChan: 96 | require.Error(t, err) 97 | require.ErrorIs(t, err, context.Canceled) 98 | } 99 | } 100 | 101 | func TestAddStreamAfterSessionClose(t *testing.T) { 102 | ctrl := gomock.NewController(t) 103 | defer ctrl.Finish() 104 | 105 | mockSess := NewMockConnection(ctrl) 106 | mockSess.EXPECT().Context().Return(context.WithValue(context.Background(), quic.ConnectionTracingKey, quic.ConnectionTracingID(1337))) 107 | 108 | sess := newSession(42, mockSess, newMockRequestStream(ctrl)) 109 | require.NoError(t, sess.CloseWithError(0, "")) 110 | 111 | str := NewMockStream(ctrl) 112 | str.EXPECT().CancelRead(sessionCloseErrorCode) 113 | str.EXPECT().CancelWrite(sessionCloseErrorCode) 114 | sess.addIncomingStream(str) 115 | 116 | ustr := NewMockStream(ctrl) 117 | ustr.EXPECT().CancelRead(sessionCloseErrorCode) 118 | sess.addIncomingUniStream(ustr) 119 | } 120 | 121 | func TestOpenStreamAfterSessionClose(t *testing.T) { 122 | ctrl := gomock.NewController(t) 123 | defer ctrl.Finish() 124 | 125 | mockSess := NewMockConnection(ctrl) 126 | mockSess.EXPECT().Context().Return(context.WithValue(context.Background(), quic.ConnectionTracingKey, quic.ConnectionTracingID(1337))) 127 | wait := make(chan struct{}) 128 | streamOpen := make(chan struct{}) 129 | mockSess.EXPECT().OpenStreamSync(gomock.Any()).DoAndReturn(func(context.Context) (quic.Stream, error) { 130 | streamOpen <- struct{}{} 131 | str := NewMockStream(ctrl) 132 | str.EXPECT().CancelRead(sessionCloseErrorCode) 133 | str.EXPECT().CancelWrite(sessionCloseErrorCode) 134 | <-wait 135 | return str, nil 136 | }) 137 | 138 | sess := newSession(42, mockSess, newMockRequestStream(ctrl)) 139 | 140 | errChan := make(chan error, 1) 141 | go func() { 142 | _, err := sess.OpenStreamSync(context.Background()) 143 | errChan <- err 144 | }() 145 | <-streamOpen 146 | 147 | require.NoError(t, sess.CloseWithError(0, "session closed")) 148 | 149 | close(wait) 150 | require.EqualError(t, <-errChan, "session closed") 151 | } 152 | 153 | func TestOpenUniStreamAfterSessionClose(t *testing.T) { 154 | ctrl := gomock.NewController(t) 155 | defer ctrl.Finish() 156 | 157 | mockSess := NewMockConnection(ctrl) 158 | mockSess.EXPECT().Context().Return(context.WithValue(context.Background(), quic.ConnectionTracingKey, quic.ConnectionTracingID(1337))) 159 | wait := make(chan struct{}) 160 | streamOpen := make(chan struct{}) 161 | mockSess.EXPECT().OpenUniStreamSync(gomock.Any()).DoAndReturn(func(context.Context) (quic.SendStream, error) { 162 | streamOpen <- struct{}{} 163 | str := NewMockStream(ctrl) 164 | str.EXPECT().CancelWrite(sessionCloseErrorCode) 165 | <-wait 166 | return str, nil 167 | }) 168 | 169 | sess := newSession(42, mockSess, newMockRequestStream(ctrl)) 170 | 171 | errChan := make(chan error, 1) 172 | go func() { 173 | _, err := sess.OpenUniStreamSync(context.Background()) 174 | errChan <- err 175 | }() 176 | <-streamOpen 177 | 178 | require.NoError(t, sess.CloseWithError(0, "session closed")) 179 | 180 | close(wait) 181 | require.EqualError(t, <-errChan, "session closed") 182 | } 183 | -------------------------------------------------------------------------------- /stream.go: -------------------------------------------------------------------------------- 1 | package webtransport 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "net" 8 | "sync" 9 | "time" 10 | 11 | "github.com/quic-go/quic-go" 12 | ) 13 | 14 | const sessionCloseErrorCode quic.StreamErrorCode = 0x170d7b68 15 | 16 | type SendStream interface { 17 | io.Writer 18 | io.Closer 19 | 20 | StreamID() quic.StreamID 21 | CancelWrite(StreamErrorCode) 22 | 23 | SetWriteDeadline(time.Time) error 24 | } 25 | 26 | type ReceiveStream interface { 27 | io.Reader 28 | 29 | StreamID() quic.StreamID 30 | CancelRead(StreamErrorCode) 31 | 32 | SetReadDeadline(time.Time) error 33 | } 34 | 35 | type Stream interface { 36 | SendStream 37 | ReceiveStream 38 | SetDeadline(time.Time) error 39 | } 40 | 41 | type sendStream struct { 42 | str quic.SendStream 43 | // WebTransport stream header. 44 | // Set by the constructor, set to nil once sent out. 45 | // Might be initialized to nil if this sendStream is part of an incoming bidirectional stream. 46 | streamHdr []byte 47 | 48 | onClose func() 49 | 50 | once sync.Once 51 | } 52 | 53 | var _ SendStream = &sendStream{} 54 | 55 | func newSendStream(str quic.SendStream, hdr []byte, onClose func()) *sendStream { 56 | return &sendStream{str: str, streamHdr: hdr, onClose: onClose} 57 | } 58 | 59 | func (s *sendStream) maybeSendStreamHeader() (err error) { 60 | s.once.Do(func() { 61 | if _, e := s.str.Write(s.streamHdr); e != nil { 62 | err = e 63 | return 64 | } 65 | s.streamHdr = nil 66 | }) 67 | return 68 | } 69 | 70 | func (s *sendStream) Write(b []byte) (int, error) { 71 | if err := s.maybeSendStreamHeader(); err != nil { 72 | return 0, err 73 | } 74 | n, err := s.str.Write(b) 75 | if err != nil && !isTimeoutError(err) { 76 | s.onClose() 77 | } 78 | return n, maybeConvertStreamError(err) 79 | } 80 | 81 | func (s *sendStream) CancelWrite(e StreamErrorCode) { 82 | s.str.CancelWrite(webtransportCodeToHTTPCode(e)) 83 | s.onClose() 84 | } 85 | 86 | func (s *sendStream) closeWithSession() { 87 | s.str.CancelWrite(sessionCloseErrorCode) 88 | } 89 | 90 | func (s *sendStream) Close() error { 91 | if err := s.maybeSendStreamHeader(); err != nil { 92 | return err 93 | } 94 | s.onClose() 95 | return maybeConvertStreamError(s.str.Close()) 96 | } 97 | 98 | func (s *sendStream) SetWriteDeadline(t time.Time) error { 99 | return maybeConvertStreamError(s.str.SetWriteDeadline(t)) 100 | } 101 | 102 | func (s *sendStream) StreamID() quic.StreamID { 103 | return s.str.StreamID() 104 | } 105 | 106 | type receiveStream struct { 107 | str quic.ReceiveStream 108 | onClose func() 109 | } 110 | 111 | var _ ReceiveStream = &receiveStream{} 112 | 113 | func newReceiveStream(str quic.ReceiveStream, onClose func()) *receiveStream { 114 | return &receiveStream{str: str, onClose: onClose} 115 | } 116 | 117 | func (s *receiveStream) Read(b []byte) (int, error) { 118 | n, err := s.str.Read(b) 119 | if err != nil && !isTimeoutError(err) { 120 | s.onClose() 121 | } 122 | return n, maybeConvertStreamError(err) 123 | } 124 | 125 | func (s *receiveStream) CancelRead(e StreamErrorCode) { 126 | s.str.CancelRead(webtransportCodeToHTTPCode(e)) 127 | s.onClose() 128 | } 129 | 130 | func (s *receiveStream) closeWithSession() { 131 | s.str.CancelRead(sessionCloseErrorCode) 132 | } 133 | 134 | func (s *receiveStream) SetReadDeadline(t time.Time) error { 135 | return maybeConvertStreamError(s.str.SetReadDeadline(t)) 136 | } 137 | 138 | func (s *receiveStream) StreamID() quic.StreamID { 139 | return s.str.StreamID() 140 | } 141 | 142 | type stream struct { 143 | *sendStream 144 | *receiveStream 145 | 146 | mx sync.Mutex 147 | sendSideClosed, recvSideClosed bool 148 | onClose func() 149 | } 150 | 151 | var _ Stream = &stream{} 152 | 153 | func newStream(str quic.Stream, hdr []byte, onClose func()) *stream { 154 | s := &stream{onClose: onClose} 155 | s.sendStream = newSendStream(str, hdr, func() { s.registerClose(true) }) 156 | s.receiveStream = newReceiveStream(str, func() { s.registerClose(false) }) 157 | return s 158 | } 159 | 160 | func (s *stream) registerClose(isSendSide bool) { 161 | s.mx.Lock() 162 | if isSendSide { 163 | s.sendSideClosed = true 164 | } else { 165 | s.recvSideClosed = true 166 | } 167 | isClosed := s.sendSideClosed && s.recvSideClosed 168 | s.mx.Unlock() 169 | 170 | if isClosed { 171 | s.onClose() 172 | } 173 | } 174 | 175 | func (s *stream) closeWithSession() { 176 | s.sendStream.closeWithSession() 177 | s.receiveStream.closeWithSession() 178 | } 179 | 180 | func (s *stream) SetDeadline(t time.Time) error { 181 | err1 := s.sendStream.SetWriteDeadline(t) 182 | err2 := s.receiveStream.SetReadDeadline(t) 183 | if err1 != nil { 184 | return err1 185 | } 186 | return err2 187 | } 188 | 189 | func (s *stream) StreamID() quic.StreamID { 190 | return s.receiveStream.StreamID() 191 | } 192 | 193 | func maybeConvertStreamError(err error) error { 194 | if err == nil { 195 | return nil 196 | } 197 | var streamErr *quic.StreamError 198 | if errors.As(err, &streamErr) { 199 | errorCode, cerr := httpCodeToWebtransportCode(streamErr.ErrorCode) 200 | if cerr != nil { 201 | return fmt.Errorf("stream reset, but failed to convert stream error %d: %w", streamErr.ErrorCode, cerr) 202 | } 203 | return &StreamError{ 204 | ErrorCode: errorCode, 205 | Remote: streamErr.Remote, 206 | } 207 | } 208 | return err 209 | } 210 | 211 | func isTimeoutError(err error) bool { 212 | nerr, ok := err.(net.Error) 213 | if !ok { 214 | return false 215 | } 216 | return nerr.Timeout() 217 | } 218 | -------------------------------------------------------------------------------- /streams_map.go: -------------------------------------------------------------------------------- 1 | package webtransport 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/quic-go/quic-go" 7 | ) 8 | 9 | type closeFunc func() 10 | 11 | // The streamsMap manages the streams of a single QUIC connection. 12 | // Note that several WebTransport sessions can share one QUIC connection. 13 | type streamsMap struct { 14 | mx sync.Mutex 15 | m map[quic.StreamID]closeFunc 16 | } 17 | 18 | func newStreamsMap() *streamsMap { 19 | return &streamsMap{m: make(map[quic.StreamID]closeFunc)} 20 | } 21 | 22 | func (s *streamsMap) AddStream(id quic.StreamID, close closeFunc) { 23 | s.mx.Lock() 24 | s.m[id] = close 25 | s.mx.Unlock() 26 | } 27 | 28 | func (s *streamsMap) RemoveStream(id quic.StreamID) { 29 | s.mx.Lock() 30 | delete(s.m, id) 31 | s.mx.Unlock() 32 | } 33 | 34 | func (s *streamsMap) CloseSession() { 35 | s.mx.Lock() 36 | defer s.mx.Unlock() 37 | 38 | for _, cl := range s.m { 39 | cl() 40 | } 41 | s.m = nil 42 | } 43 | -------------------------------------------------------------------------------- /tls_utils_test.go: -------------------------------------------------------------------------------- 1 | package webtransport_test 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "crypto/tls" 7 | "crypto/x509" 8 | "crypto/x509/pkix" 9 | "log" 10 | "math/big" 11 | "time" 12 | ) 13 | 14 | const alpn = "webtransport-go / quic-go" 15 | 16 | var ( 17 | tlsConf *tls.Config 18 | certPool *x509.CertPool 19 | ) 20 | 21 | func init() { 22 | ca, caPrivateKey, err := generateCA() 23 | if err != nil { 24 | log.Fatal("failed to generate CA certificate:", err) 25 | } 26 | leafCert, leafPrivateKey, err := generateLeafCert(ca, caPrivateKey) 27 | if err != nil { 28 | log.Fatal("failed to generate leaf certificate:", err) 29 | } 30 | certPool = x509.NewCertPool() 31 | certPool.AddCert(ca) 32 | tlsConf = &tls.Config{ 33 | Certificates: []tls.Certificate{{ 34 | Certificate: [][]byte{leafCert.Raw}, 35 | PrivateKey: leafPrivateKey, 36 | }}, 37 | NextProtos: []string{alpn}, 38 | } 39 | } 40 | 41 | func generateCA() (*x509.Certificate, *rsa.PrivateKey, error) { 42 | certTempl := &x509.Certificate{ 43 | SerialNumber: big.NewInt(2019), 44 | Subject: pkix.Name{}, 45 | NotBefore: time.Now(), 46 | NotAfter: time.Now().Add(24 * time.Hour), 47 | IsCA: true, 48 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, 49 | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, 50 | BasicConstraintsValid: true, 51 | } 52 | caPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048) 53 | if err != nil { 54 | return nil, nil, err 55 | } 56 | caBytes, err := x509.CreateCertificate(rand.Reader, certTempl, certTempl, &caPrivateKey.PublicKey, caPrivateKey) 57 | if err != nil { 58 | return nil, nil, err 59 | } 60 | ca, err := x509.ParseCertificate(caBytes) 61 | if err != nil { 62 | return nil, nil, err 63 | } 64 | return ca, caPrivateKey, nil 65 | } 66 | 67 | func generateLeafCert(ca *x509.Certificate, caPrivateKey *rsa.PrivateKey) (*x509.Certificate, *rsa.PrivateKey, error) { 68 | certTempl := &x509.Certificate{ 69 | SerialNumber: big.NewInt(1), 70 | DNSNames: []string{"localhost"}, 71 | NotBefore: time.Now(), 72 | NotAfter: time.Now().Add(24 * time.Hour), 73 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, 74 | KeyUsage: x509.KeyUsageDigitalSignature, 75 | } 76 | privKey, err := rsa.GenerateKey(rand.Reader, 2048) 77 | if err != nil { 78 | return nil, nil, err 79 | } 80 | certBytes, err := x509.CreateCertificate(rand.Reader, certTempl, ca, &privKey.PublicKey, caPrivateKey) 81 | if err != nil { 82 | return nil, nil, err 83 | } 84 | cert, err := x509.ParseCertificate(certBytes) 85 | if err != nil { 86 | return nil, nil, err 87 | } 88 | return cert, privKey, nil 89 | } 90 | -------------------------------------------------------------------------------- /version.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v0.8.0" 3 | } 4 | -------------------------------------------------------------------------------- /webtransport_test.go: -------------------------------------------------------------------------------- 1 | package webtransport_test 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net" 10 | "net/http" 11 | "strconv" 12 | "strings" 13 | "sync" 14 | "testing" 15 | "time" 16 | 17 | "golang.org/x/exp/rand" 18 | 19 | "github.com/quic-go/webtransport-go" 20 | 21 | "github.com/quic-go/quic-go" 22 | "github.com/quic-go/quic-go/http3" 23 | "github.com/quic-go/quic-go/qlog" 24 | 25 | "github.com/stretchr/testify/require" 26 | ) 27 | 28 | func runServer(t *testing.T, s *webtransport.Server) (addr *net.UDPAddr, close func()) { 29 | laddr, err := net.ResolveUDPAddr("udp", "localhost:0") 30 | require.NoError(t, err) 31 | udpConn, err := net.ListenUDP("udp", laddr) 32 | require.NoError(t, err) 33 | 34 | servErr := make(chan error, 1) 35 | go func() { 36 | servErr <- s.Serve(udpConn) 37 | }() 38 | 39 | return udpConn.LocalAddr().(*net.UDPAddr), func() { 40 | require.NoError(t, s.Close()) 41 | <-servErr 42 | udpConn.Close() 43 | } 44 | } 45 | 46 | func establishSession(t *testing.T, handler func(*webtransport.Session)) (sess *webtransport.Session, close func()) { 47 | s := &webtransport.Server{ 48 | H3: http3.Server{ 49 | TLSConfig: tlsConf, 50 | QUICConfig: &quic.Config{Tracer: qlog.DefaultConnectionTracer, EnableDatagrams: true}, 51 | }, 52 | } 53 | addHandler(t, s, handler) 54 | 55 | addr, closeServer := runServer(t, s) 56 | d := webtransport.Dialer{ 57 | TLSClientConfig: &tls.Config{RootCAs: certPool}, 58 | QUICConfig: &quic.Config{Tracer: qlog.DefaultConnectionTracer, EnableDatagrams: true}, 59 | } 60 | defer d.Close() 61 | url := fmt.Sprintf("https://localhost:%d/webtransport", addr.Port) 62 | rsp, sess, err := d.Dial(context.Background(), url, nil) 63 | require.NoError(t, err) 64 | require.Equal(t, 200, rsp.StatusCode) 65 | return sess, func() { 66 | closeServer() 67 | s.Close() 68 | d.Close() 69 | } 70 | } 71 | 72 | // opens a new stream on the connection, 73 | // sends data and checks the echoed data. 74 | func sendDataAndCheckEcho(t *testing.T, sess *webtransport.Session) { 75 | t.Helper() 76 | data := getRandomData(5 * 1024) 77 | str, err := sess.OpenStream() 78 | require.NoError(t, err) 79 | str.SetDeadline(time.Now().Add(time.Second)) 80 | _, err = str.Write(data) 81 | require.NoError(t, err) 82 | require.NoError(t, str.Close()) 83 | reply, err := io.ReadAll(str) 84 | require.NoError(t, err) 85 | require.Equal(t, data, reply) 86 | } 87 | 88 | func addHandler(t *testing.T, s *webtransport.Server, connHandler func(*webtransport.Session)) { 89 | t.Helper() 90 | mux := http.NewServeMux() 91 | mux.HandleFunc("/webtransport", func(w http.ResponseWriter, r *http.Request) { 92 | conn, err := s.Upgrade(w, r) 93 | if err != nil { 94 | t.Logf("upgrading failed: %s", err) 95 | w.WriteHeader(404) // TODO: better error code 96 | return 97 | } 98 | connHandler(conn) 99 | }) 100 | s.H3.Handler = mux 101 | } 102 | 103 | func newEchoHandler(t *testing.T) func(*webtransport.Session) { 104 | return func(sess *webtransport.Session) { 105 | for { 106 | str, err := sess.AcceptStream(context.Background()) 107 | if err != nil { 108 | break 109 | } 110 | _, err = io.CopyBuffer(str, str, make([]byte, 100)) 111 | require.NoError(t, err) 112 | require.NoError(t, str.Close()) 113 | } 114 | } 115 | } 116 | 117 | func getRandomData(l int) []byte { 118 | data := make([]byte, l) 119 | rand.Read(data) 120 | return data 121 | } 122 | 123 | func TestBidirectionalStreamsDataTransfer(t *testing.T) { 124 | t.Run("client-initiated", func(t *testing.T) { 125 | conn, closeServer := establishSession(t, newEchoHandler(t)) 126 | defer closeServer() 127 | defer conn.CloseWithError(0, "") 128 | 129 | sendDataAndCheckEcho(t, conn) 130 | }) 131 | 132 | t.Run("server-initiated", func(t *testing.T) { 133 | done := make(chan struct{}) 134 | conn, closeServer := establishSession(t, func(sess *webtransport.Session) { 135 | sendDataAndCheckEcho(t, sess) 136 | close(done) // don't defer this, the HTTP handler catches panics 137 | }) 138 | defer closeServer() 139 | defer conn.CloseWithError(0, "") 140 | 141 | go newEchoHandler(t)(conn) 142 | <-done 143 | }) 144 | } 145 | 146 | func TestStreamsImmediateClose(t *testing.T) { 147 | t.Run("bidirectional streams", func(t *testing.T) { 148 | t.Run("client-initiated", func(t *testing.T) { 149 | done := make(chan struct{}) 150 | conn, closeServer := establishSession(t, func(c *webtransport.Session) { 151 | str, err := c.AcceptStream(context.Background()) 152 | require.NoError(t, err) 153 | n, err := str.Read([]byte{0}) 154 | require.Zero(t, n) 155 | require.ErrorIs(t, err, io.EOF) 156 | require.NoError(t, str.Close()) 157 | close(done) // don't defer this, the HTTP handler catches panics 158 | }) 159 | defer closeServer() 160 | defer conn.CloseWithError(0, "") 161 | 162 | str, err := conn.OpenStream() 163 | require.NoError(t, err) 164 | require.NoError(t, str.Close()) 165 | n, err := str.Read([]byte{0}) 166 | require.Zero(t, n) 167 | require.ErrorIs(t, err, io.EOF) 168 | <-done 169 | }) 170 | 171 | t.Run("server-initiated", func(t *testing.T) { 172 | done := make(chan struct{}) 173 | conn, closeServer := establishSession(t, func(c *webtransport.Session) { 174 | str, err := c.OpenStream() 175 | require.NoError(t, err) 176 | require.NoError(t, str.Close()) 177 | n, err := str.Read([]byte{0}) 178 | require.Zero(t, n) 179 | require.ErrorIs(t, err, io.EOF) 180 | require.NoError(t, c.CloseWithError(0, "")) 181 | close(done) // don't defer this, the HTTP handler catches panics 182 | }) 183 | defer closeServer() 184 | defer conn.CloseWithError(0, "") 185 | 186 | str, err := conn.AcceptStream(context.Background()) 187 | require.NoError(t, err) 188 | n, err := str.Read([]byte{0}) 189 | require.Zero(t, n) 190 | require.ErrorIs(t, err, io.EOF) 191 | require.NoError(t, str.Close()) 192 | <-done 193 | }) 194 | }) 195 | 196 | t.Run("unidirectional", func(t *testing.T) { 197 | t.Run("client-initiated", func(t *testing.T) { 198 | sess, closeServer := establishSession(t, func(sess *webtransport.Session) { 199 | defer sess.CloseWithError(0, "") 200 | str, err := sess.AcceptUniStream(context.Background()) 201 | require.NoError(t, err) 202 | n, err := str.Read([]byte{0}) 203 | require.Zero(t, n) 204 | require.ErrorIs(t, err, io.EOF) 205 | }) 206 | defer closeServer() 207 | 208 | str, err := sess.OpenUniStream() 209 | require.NoError(t, err) 210 | require.NoError(t, str.Close()) 211 | <-sess.Context().Done() 212 | }) 213 | 214 | t.Run("server-initiated", func(t *testing.T) { 215 | sess, closeServer := establishSession(t, func(sess *webtransport.Session) { 216 | str, err := sess.OpenUniStream() 217 | require.NoError(t, err) 218 | require.NoError(t, str.Close()) 219 | }) 220 | defer closeServer() 221 | defer sess.CloseWithError(0, "") 222 | 223 | str, err := sess.AcceptUniStream(context.Background()) 224 | require.NoError(t, err) 225 | n, err := str.Read([]byte{0}) 226 | require.Zero(t, n) 227 | require.ErrorIs(t, err, io.EOF) 228 | }) 229 | }) 230 | } 231 | 232 | func TestStreamsImmediateReset(t *testing.T) { 233 | // This tests ensures that we correctly process the error code that occurs when quic-go reads the frame type. 234 | // If we don't process the error code correctly and fail to hijack the stream, 235 | // quic-go will see a bidirectional stream opened by the server, which is a connection error. 236 | done := make(chan struct{}) 237 | defer close(done) 238 | sess, closeServer := establishSession(t, func(c *webtransport.Session) { 239 | for i := 0; i < 50; i++ { 240 | str, err := c.OpenStream() 241 | require.NoError(t, err) 242 | 243 | var wg sync.WaitGroup 244 | wg.Add(2) 245 | go func() { 246 | defer wg.Done() 247 | str.CancelWrite(42) 248 | }() 249 | 250 | go func() { 251 | defer wg.Done() 252 | str.Write([]byte("foobar")) 253 | }() 254 | 255 | wg.Wait() 256 | } 257 | }) 258 | defer closeServer() 259 | defer sess.CloseWithError(0, "") 260 | 261 | ctx, cancel := context.WithTimeout(context.Background(), scaleDuration(100*time.Millisecond)) 262 | defer cancel() 263 | for { 264 | _, err := sess.AcceptStream(ctx) 265 | if err == context.DeadlineExceeded { 266 | break 267 | } 268 | require.NoError(t, err) 269 | } 270 | } 271 | 272 | func TestUnidirectionalStreams(t *testing.T) { 273 | sess, closeServer := establishSession(t, func(sess *webtransport.Session) { 274 | // Accept a unidirectional stream, read all of its contents, 275 | // and echo it on a newly opened unidirectional stream. 276 | str, err := sess.AcceptUniStream(context.Background()) 277 | require.NoError(t, err) 278 | data, err := io.ReadAll(str) 279 | require.NoError(t, err) 280 | rstr, err := sess.OpenUniStream() 281 | require.NoError(t, err) 282 | _, err = rstr.Write(data) 283 | require.NoError(t, err) 284 | require.NoError(t, rstr.Close()) 285 | }) 286 | defer closeServer() 287 | defer sess.CloseWithError(0, "") 288 | 289 | str, err := sess.OpenUniStream() 290 | require.NoError(t, err) 291 | data := getRandomData(10 * 1024) 292 | _, err = str.Write(data) 293 | require.NoError(t, err) 294 | require.NoError(t, str.Close()) 295 | rstr, err := sess.AcceptUniStream(context.Background()) 296 | require.NoError(t, err) 297 | rdata, err := io.ReadAll(rstr) 298 | require.NoError(t, err) 299 | require.Equal(t, data, rdata) 300 | } 301 | 302 | func TestMultipleClients(t *testing.T) { 303 | const numClients = 5 304 | s := &webtransport.Server{ 305 | H3: http3.Server{TLSConfig: tlsConf}, 306 | } 307 | defer s.Close() 308 | addHandler(t, s, newEchoHandler(t)) 309 | 310 | addr, closeServer := runServer(t, s) 311 | defer closeServer() 312 | 313 | var wg sync.WaitGroup 314 | wg.Add(numClients) 315 | for i := 0; i < numClients; i++ { 316 | go func() { 317 | defer wg.Done() 318 | d := webtransport.Dialer{ 319 | TLSClientConfig: &tls.Config{RootCAs: certPool}, 320 | QUICConfig: &quic.Config{Tracer: qlog.DefaultConnectionTracer, EnableDatagrams: true}, 321 | } 322 | defer d.Close() 323 | url := fmt.Sprintf("https://localhost:%d/webtransport", addr.Port) 324 | rsp, conn, err := d.Dial(context.Background(), url, nil) 325 | require.NoError(t, err) 326 | require.Equal(t, 200, rsp.StatusCode) 327 | sendDataAndCheckEcho(t, conn) 328 | }() 329 | } 330 | wg.Wait() 331 | } 332 | 333 | func TestStreamResetError(t *testing.T) { 334 | const errorCode webtransport.StreamErrorCode = 127 335 | strChan := make(chan webtransport.Stream, 1) 336 | sess, closeServer := establishSession(t, func(sess *webtransport.Session) { 337 | for { 338 | str, err := sess.AcceptStream(context.Background()) 339 | if err != nil { 340 | return 341 | } 342 | str.CancelRead(errorCode) 343 | str.CancelWrite(errorCode) 344 | strChan <- str 345 | } 346 | }) 347 | defer closeServer() 348 | 349 | // client side 350 | str, err := sess.OpenStream() 351 | require.NoError(t, err) 352 | _, err = str.Write([]byte("foobar")) 353 | require.NoError(t, err) 354 | _, err = str.Read([]byte{0}) 355 | require.Error(t, err) 356 | var strErr *webtransport.StreamError 357 | require.True(t, errors.As(err, &strErr)) 358 | require.Equal(t, strErr.ErrorCode, errorCode) 359 | require.True(t, strErr.Remote) 360 | 361 | // server side 362 | str = <-strChan 363 | _, err = str.Read([]byte{0}) 364 | require.Error(t, err) 365 | require.True(t, errors.As(err, &strErr)) 366 | require.Equal(t, strErr.ErrorCode, errorCode) 367 | require.False(t, strErr.Remote) 368 | } 369 | 370 | func TestShutdown(t *testing.T) { 371 | done := make(chan struct{}) 372 | sess, closeServer := establishSession(t, func(sess *webtransport.Session) { 373 | sess.CloseWithError(1337, "foobar") 374 | var sessErr *webtransport.SessionError 375 | _, err := sess.OpenStream() 376 | require.True(t, errors.As(err, &sessErr)) 377 | require.False(t, sessErr.Remote) 378 | require.Equal(t, webtransport.SessionErrorCode(1337), sessErr.ErrorCode) 379 | require.Equal(t, "foobar", sessErr.Message) 380 | _, err = sess.OpenUniStream() 381 | require.True(t, errors.As(err, &sessErr)) 382 | require.False(t, sessErr.Remote) 383 | 384 | close(done) // don't defer this, the HTTP handler catches panics 385 | }) 386 | defer closeServer() 387 | 388 | var sessErr *webtransport.SessionError 389 | _, err := sess.AcceptStream(context.Background()) 390 | require.True(t, errors.As(err, &sessErr)) 391 | require.True(t, sessErr.Remote) 392 | require.Equal(t, webtransport.SessionErrorCode(1337), sessErr.ErrorCode) 393 | require.Equal(t, "foobar", sessErr.Message) 394 | _, err = sess.AcceptUniStream(context.Background()) 395 | require.Error(t, err) 396 | <-done 397 | } 398 | 399 | func TestOpenStreamSyncShutdown(t *testing.T) { 400 | runTest := func(t *testing.T, openStream, openStreamSync func() error, done chan struct{}) { 401 | t.Helper() 402 | 403 | // open as many streams as the server lets us 404 | for { 405 | if err := openStream(); err != nil { 406 | break 407 | } 408 | } 409 | 410 | const num = 3 411 | errChan := make(chan error, num) 412 | for i := 0; i < num; i++ { 413 | go func() { errChan <- openStreamSync() }() 414 | } 415 | 416 | // make sure the 3 calls to OpenStreamSync are actually blocked 417 | require.Never(t, func() bool { return len(errChan) > 0 }, 100*time.Millisecond, 10*time.Millisecond) 418 | close(done) 419 | require.Eventually(t, func() bool { return len(errChan) == num }, scaleDuration(100*time.Millisecond), 10*time.Millisecond) 420 | for i := 0; i < num; i++ { 421 | err := <-errChan 422 | var sessErr *webtransport.SessionError 423 | require.ErrorAs(t, err, &sessErr) 424 | } 425 | } 426 | 427 | t.Run("bidirectional streams", func(t *testing.T) { 428 | done := make(chan struct{}) 429 | sess, closeServer := establishSession(t, func(sess *webtransport.Session) { 430 | <-done 431 | sess.CloseWithError(0, "") 432 | }) 433 | defer closeServer() 434 | 435 | runTest(t, 436 | func() error { _, err := sess.OpenStream(); return err }, 437 | func() error { _, err := sess.OpenStreamSync(context.Background()); return err }, 438 | done, 439 | ) 440 | }) 441 | 442 | t.Run("unidirectional streams", func(t *testing.T) { 443 | done := make(chan struct{}) 444 | sess, closeServer := establishSession(t, func(sess *webtransport.Session) { 445 | <-done 446 | sess.CloseWithError(0, "") 447 | }) 448 | defer closeServer() 449 | 450 | runTest(t, 451 | func() error { _, err := sess.OpenUniStream(); return err }, 452 | func() error { _, err := sess.OpenUniStreamSync(context.Background()); return err }, 453 | done, 454 | ) 455 | }) 456 | } 457 | 458 | func TestCheckOrigin(t *testing.T) { 459 | type tc struct { 460 | Name string 461 | CheckOrigin func(*http.Request) bool 462 | Origin string 463 | Result bool 464 | } 465 | 466 | tcs := []tc{ 467 | { 468 | Name: "using default CheckOrigin, no Origin header", 469 | Result: true, 470 | }, 471 | { 472 | Name: "using default CheckOrigin, Origin: localhost", 473 | Origin: "https://localhost:%port%", 474 | Result: true, 475 | }, 476 | { 477 | Name: "using default CheckOrigin, Origin: google.com", 478 | Origin: "google.com", 479 | Result: false, 480 | }, 481 | { 482 | Name: "using custom CheckOrigin, always correct", 483 | CheckOrigin: func(r *http.Request) bool { return true }, 484 | Origin: "google.com", 485 | Result: true, 486 | }, 487 | { 488 | Name: "using custom CheckOrigin, always incorrect", 489 | CheckOrigin: func(r *http.Request) bool { return false }, 490 | Origin: "google.com", 491 | Result: false, 492 | }, 493 | } 494 | 495 | for _, tc := range tcs { 496 | tc := tc 497 | t.Run(tc.Name, func(t *testing.T) { 498 | s := &webtransport.Server{ 499 | H3: http3.Server{TLSConfig: tlsConf}, 500 | CheckOrigin: tc.CheckOrigin, 501 | } 502 | defer s.Close() 503 | addHandler(t, s, newEchoHandler(t)) 504 | 505 | addr, closeServer := runServer(t, s) 506 | defer closeServer() 507 | 508 | d := webtransport.Dialer{ 509 | TLSClientConfig: &tls.Config{RootCAs: certPool}, 510 | QUICConfig: &quic.Config{Tracer: qlog.DefaultConnectionTracer, EnableDatagrams: true}, 511 | } 512 | defer d.Close() 513 | url := fmt.Sprintf("https://localhost:%d/webtransport", addr.Port) 514 | hdr := make(http.Header) 515 | hdr.Add("Origin", strings.ReplaceAll(tc.Origin, "%port%", strconv.Itoa(addr.Port))) 516 | rsp, conn, err := d.Dial(context.Background(), url, hdr) 517 | if tc.Result { 518 | require.NoError(t, err) 519 | require.Equal(t, 200, rsp.StatusCode) 520 | defer conn.CloseWithError(0, "") 521 | } else { 522 | require.Equal(t, 404, rsp.StatusCode) 523 | } 524 | }) 525 | } 526 | } 527 | 528 | func TestCloseStreamsOnSessionClose(t *testing.T) { 529 | accepted := make(chan struct{}) 530 | sess, closeServer := establishSession(t, func(sess *webtransport.Session) { 531 | str, err := sess.OpenStream() 532 | require.NoError(t, err) 533 | _, err = str.Write([]byte("foobar")) 534 | require.NoError(t, err) 535 | ustr, err := sess.OpenUniStream() 536 | require.NoError(t, err) 537 | _, err = ustr.Write([]byte("foobar")) 538 | require.NoError(t, err) 539 | <-accepted 540 | sess.CloseWithError(0, "") 541 | _, err = str.Read([]byte{0}) 542 | require.Error(t, err) 543 | _, err = ustr.Write([]byte{0}) 544 | require.Error(t, err) 545 | _, err = ustr.Write([]byte{0}) 546 | require.Error(t, err) 547 | }) 548 | defer closeServer() 549 | 550 | str, err := sess.AcceptStream(context.Background()) 551 | require.NoError(t, err) 552 | ustr, err := sess.AcceptUniStream(context.Background()) 553 | require.NoError(t, err) 554 | close(accepted) 555 | str.Read(make([]byte, 6)) // read the foobar 556 | _, err = str.Read([]byte{0}) 557 | require.Error(t, err) 558 | ustr.Read(make([]byte, 6)) // read the foobar 559 | _, err = ustr.Read([]byte{0}) 560 | require.Error(t, err) 561 | } 562 | 563 | func TestWriteCloseRace(t *testing.T) { 564 | ch := make(chan struct{}) 565 | sess, closeServer := establishSession(t, func(sess *webtransport.Session) { 566 | str, err := sess.AcceptStream(context.Background()) 567 | if err != nil { 568 | return 569 | } 570 | defer str.Close() 571 | <-ch 572 | }) 573 | defer closeServer() 574 | str, err := sess.OpenStream() 575 | require.NoError(t, err) 576 | ready := make(chan struct{}, 2) 577 | var wg sync.WaitGroup 578 | wg.Add(2) 579 | 580 | go func() { 581 | ready <- struct{}{} 582 | wg.Wait() 583 | str.Write([]byte("foobar")) 584 | ready <- struct{}{} 585 | }() 586 | go func() { 587 | ready <- struct{}{} 588 | wg.Wait() 589 | str.Close() 590 | ready <- struct{}{} 591 | }() 592 | <-ready 593 | <-ready 594 | wg.Add(-2) 595 | <-ready 596 | <-ready 597 | close(ch) 598 | } 599 | 600 | func TestDatagrams(t *testing.T) { 601 | const num = 100 602 | var mx sync.Mutex 603 | m := make(map[string]bool, num) 604 | 605 | var counter int 606 | done := make(chan struct{}) 607 | serverErrChan := make(chan error, 1) 608 | sess, closeServer := establishSession(t, func(sess *webtransport.Session) { 609 | defer close(done) 610 | for { 611 | b, err := sess.ReceiveDatagram(context.Background()) 612 | if err != nil { 613 | return 614 | } 615 | mx.Lock() 616 | if _, ok := m[string(b)]; !ok { 617 | serverErrChan <- errors.New("received unexpected datagram") 618 | return 619 | } 620 | m[string(b)] = true 621 | mx.Unlock() 622 | counter++ 623 | } 624 | }) 625 | defer closeServer() 626 | 627 | errChan := make(chan error, 1) 628 | 629 | for i := 0; i < num; i++ { 630 | b := make([]byte, 800) 631 | rand.Read(b) 632 | mx.Lock() 633 | m[string(b)] = false 634 | mx.Unlock() 635 | if err := sess.SendDatagram(b); err != nil { 636 | break 637 | } 638 | } 639 | time.Sleep(scaleDuration(10 * time.Millisecond)) 640 | sess.CloseWithError(0, "") 641 | select { 642 | case err := <-serverErrChan: 643 | t.Fatal(err) 644 | case err := <-errChan: 645 | t.Fatal(err) 646 | case <-done: 647 | t.Logf("sent: %d, received: %d", num, counter) 648 | require.Greater(t, counter, num*4/5) 649 | case <-time.After(5 * time.Second): 650 | t.Fatal("timeout") 651 | } 652 | } 653 | --------------------------------------------------------------------------------