All checks were successful
Create and publish a Docker image 🚀 / build-and-push-image (push) Successful in 1m49s
274 lines
7.3 KiB
Go
274 lines
7.3 KiB
Go
package webtransport
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/quic-go/quic-go"
|
|
"github.com/quic-go/quic-go/http3"
|
|
"github.com/quic-go/quic-go/quicvarint"
|
|
|
|
"github.com/dunglas/httpsfv"
|
|
)
|
|
|
|
var errNoWebTransport = errors.New("server didn't enable WebTransport")
|
|
|
|
type Dialer struct {
|
|
// TLSClientConfig is the TLS client config used when dialing the QUIC connection.
|
|
// It must set the h3 ALPN.
|
|
TLSClientConfig *tls.Config
|
|
|
|
// QUICConfig is the QUIC config used when dialing the QUIC connection.
|
|
QUICConfig *quic.Config
|
|
|
|
// ApplicationProtocols is a list of application protocols that can be negotiated,
|
|
// see section 3.3 of https://www.ietf.org/archive/id/draft-ietf-webtrans-http3-14 for details.
|
|
ApplicationProtocols []string
|
|
|
|
// StreamReorderingTime is the time an incoming WebTransport stream that cannot be associated
|
|
// with a session is buffered.
|
|
// This can happen if the response to a CONNECT request (that creates a new session) is reordered,
|
|
// and arrives after the first WebTransport stream(s) for that session.
|
|
// Defaults to 5 seconds.
|
|
StreamReorderingTimeout time.Duration
|
|
|
|
// DialAddr is the function used to dial the underlying QUIC connection.
|
|
// If unset, quic.DialAddrEarly will be used.
|
|
DialAddr func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error)
|
|
|
|
ctx context.Context
|
|
ctxCancel context.CancelFunc
|
|
|
|
initOnce sync.Once
|
|
}
|
|
|
|
func (d *Dialer) init() {
|
|
d.ctx, d.ctxCancel = context.WithCancel(context.Background())
|
|
}
|
|
|
|
func (d *Dialer) Dial(ctx context.Context, urlStr string, reqHdr http.Header) (*http.Response, *Session, error) {
|
|
d.initOnce.Do(func() { d.init() })
|
|
|
|
quicConf := d.QUICConfig
|
|
if quicConf == nil {
|
|
quicConf = &quic.Config{
|
|
EnableDatagrams: true,
|
|
EnableStreamResetPartialDelivery: true,
|
|
}
|
|
} else {
|
|
if !d.QUICConfig.EnableDatagrams {
|
|
return nil, nil, errors.New("webtransport: DATAGRAM support required, enable it via QUICConfig.EnableDatagrams")
|
|
}
|
|
if !d.QUICConfig.EnableStreamResetPartialDelivery {
|
|
return nil, nil, errors.New("webtransport: stream reset partial delivery required, enable it via QUICConfig.EnableStreamResetPartialDelivery")
|
|
}
|
|
}
|
|
|
|
tlsConf := d.TLSClientConfig
|
|
if tlsConf == nil {
|
|
tlsConf = &tls.Config{}
|
|
} else {
|
|
tlsConf = tlsConf.Clone()
|
|
}
|
|
if len(tlsConf.NextProtos) == 0 {
|
|
tlsConf.NextProtos = []string{http3.NextProtoH3}
|
|
}
|
|
|
|
u, err := url.Parse(urlStr)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if reqHdr == nil {
|
|
reqHdr = http.Header{}
|
|
}
|
|
if len(d.ApplicationProtocols) > 0 && reqHdr.Get(wtAvailableProtocolsHeader) == "" {
|
|
list := httpsfv.List{}
|
|
for _, protocol := range d.ApplicationProtocols {
|
|
list = append(list, httpsfv.NewItem(protocol))
|
|
}
|
|
protocols, err := httpsfv.Marshal(list)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to marshal application protocols: %w", err)
|
|
}
|
|
reqHdr.Set(wtAvailableProtocolsHeader, protocols)
|
|
}
|
|
|
|
req := &http.Request{
|
|
Method: http.MethodConnect,
|
|
Header: reqHdr,
|
|
Proto: "webtransport",
|
|
Host: u.Host,
|
|
URL: u,
|
|
}
|
|
req = req.WithContext(ctx)
|
|
|
|
dialAddr := d.DialAddr
|
|
if dialAddr == nil {
|
|
dialAddr = quic.DialAddrEarly
|
|
}
|
|
qconn, err := dialAddr(ctx, u.Host, tlsConf, quicConf)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
tr := &http3.Transport{EnableDatagrams: true}
|
|
rsp, sess, err := d.handleConn(ctx, tr, qconn, req)
|
|
if err != nil {
|
|
// TODO: use a more specific error code
|
|
// see https://github.com/ietf-wg-webtrans/draft-ietf-webtrans-http3/issues/245
|
|
qconn.CloseWithError(quic.ApplicationErrorCode(http3.ErrCodeNoError), "")
|
|
tr.Close()
|
|
return rsp, nil, err
|
|
}
|
|
context.AfterFunc(sess.Context(), func() {
|
|
qconn.CloseWithError(quic.ApplicationErrorCode(http3.ErrCodeNoError), "")
|
|
tr.Close()
|
|
})
|
|
return rsp, sess, nil
|
|
}
|
|
|
|
func (d *Dialer) handleConn(ctx context.Context, tr *http3.Transport, qconn *quic.Conn, req *http.Request) (*http.Response, *Session, error) {
|
|
timeout := d.StreamReorderingTimeout
|
|
if timeout == 0 {
|
|
timeout = 5 * time.Second
|
|
}
|
|
sessMgr := newSessionManager(timeout)
|
|
context.AfterFunc(qconn.Context(), sessMgr.Close)
|
|
|
|
conn := tr.NewRawClientConn(qconn)
|
|
|
|
go func() {
|
|
for {
|
|
str, err := qconn.AcceptStream(context.Background())
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
typ, err := quicvarint.Peek(str)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if typ != webTransportFrameType {
|
|
conn.HandleBidirectionalStream(str)
|
|
return
|
|
}
|
|
// read the frame type (already peeked above)
|
|
if _, err := quicvarint.Read(quicvarint.NewReader(str)); err != nil {
|
|
return
|
|
}
|
|
// read the session ID
|
|
id, err := quicvarint.Read(quicvarint.NewReader(str))
|
|
if err != nil {
|
|
return
|
|
}
|
|
sessMgr.AddStream(str, sessionID(id))
|
|
}()
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
for {
|
|
str, err := qconn.AcceptUniStream(context.Background())
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
typ, err := quicvarint.Peek(str)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if typ != webTransportUniStreamType {
|
|
conn.HandleUnidirectionalStream(str)
|
|
return
|
|
}
|
|
// read the stream type (already peeked above)
|
|
if _, err := quicvarint.Read(quicvarint.NewReader(str)); err != nil {
|
|
return
|
|
}
|
|
// read the session ID
|
|
id, err := quicvarint.Read(quicvarint.NewReader(str))
|
|
if err != nil {
|
|
str.CancelRead(quic.StreamErrorCode(http3.ErrCodeGeneralProtocolError))
|
|
return
|
|
}
|
|
sessMgr.AddUniStream(str, sessionID(id))
|
|
}()
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case <-conn.ReceivedSettings():
|
|
case <-ctx.Done():
|
|
return nil, nil, fmt.Errorf("error waiting for HTTP/3 settings: %w", context.Cause(ctx))
|
|
case <-d.ctx.Done():
|
|
return nil, nil, context.Cause(d.ctx)
|
|
}
|
|
settings := conn.Settings()
|
|
if !settings.EnableExtendedConnect {
|
|
return nil, nil, errors.New("server didn't enable Extended CONNECT")
|
|
}
|
|
if !settings.EnableDatagrams {
|
|
return nil, nil, errors.New("server didn't enable HTTP/3 datagram support")
|
|
}
|
|
if settings.Other == nil {
|
|
return nil, nil, errNoWebTransport
|
|
}
|
|
s, ok := settings.Other[settingsEnableWebtransport]
|
|
if !ok || s != 1 {
|
|
return nil, nil, errNoWebTransport
|
|
}
|
|
|
|
requestStr, err := conn.OpenRequestStream(ctx)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if err := requestStr.SendRequestHeader(req); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
// TODO(#136): create the session to allow optimistic opening of streams and sending of datagrams
|
|
rsp, err := requestStr.ReadResponse()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if rsp.StatusCode < 200 || rsp.StatusCode >= 300 {
|
|
return rsp, nil, fmt.Errorf("received status %d", rsp.StatusCode)
|
|
}
|
|
var protocol string
|
|
if protocolHeader, ok := rsp.Header[http.CanonicalHeaderKey(wtProtocolHeader)]; ok {
|
|
protocol = d.negotiateProtocol(protocolHeader)
|
|
}
|
|
sessID := sessionID(requestStr.StreamID())
|
|
sess := newSession(context.WithoutCancel(ctx), sessID, qconn, requestStr, protocol)
|
|
sessMgr.AddSession(sessID, sess)
|
|
return rsp, sess, nil
|
|
}
|
|
|
|
func (d *Dialer) negotiateProtocol(theirs []string) string {
|
|
negotiatedProtocolItem, err := httpsfv.UnmarshalItem(theirs)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
negotiatedProtocol, ok := negotiatedProtocolItem.Value.(string)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
if !slices.Contains(d.ApplicationProtocols, negotiatedProtocol) {
|
|
return ""
|
|
}
|
|
return negotiatedProtocol
|
|
}
|
|
|
|
func (d *Dialer) Close() error {
|
|
d.ctxCancel()
|
|
return nil
|
|
}
|