Skip to content

Commit a66ebe7

Browse files
authored
Merge pull request #310 from ryanfowler/http3
Add support for HTTP/3
2 parents 93b6689 + 2fa84f6 commit a66ebe7

File tree

10 files changed

+211
-76
lines changed

10 files changed

+211
-76
lines changed

docs/CONFIG.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,8 +249,7 @@ redirects = 10
249249

250250
#### `http`
251251
**Type**: String
252-
**Values**: `1`, `2`
253-
**Default**: `2`
252+
**Values**: `1`, `2`, `3`
254253

255254
Specify the highest allowed HTTP version.
256255

docs/USAGE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ fetch --tls 1.3 example.com
241241

242242
**Flag**: `--http VERSION`
243243

244-
Specify the exact HTTP version to use. Must be `1` or `2`.
244+
Specify the exact HTTP version to use. Must be `1`, `2`, or `3`.
245245
By default, HTTP/2 is preferred but will fallback to using HTTP/1.1.
246246

247247
```sh

go.mod

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.25.1
44

55
require (
66
github.com/klauspost/compress v1.18.0
7+
github.com/quic-go/quic-go v0.54.0
78
github.com/tinylib/msgp v1.4.0
89
golang.org/x/image v0.31.0
910
golang.org/x/net v0.44.0
@@ -12,5 +13,11 @@ require (
1213

1314
require (
1415
github.com/philhofer/fwd v1.2.0 // indirect
16+
github.com/quic-go/qpack v0.5.1 // indirect
17+
go.uber.org/mock v0.5.0 // indirect
18+
golang.org/x/crypto v0.42.0 // indirect
19+
golang.org/x/mod v0.27.0 // indirect
20+
golang.org/x/sync v0.17.0 // indirect
1521
golang.org/x/text v0.29.0 // indirect
22+
golang.org/x/tools v0.36.0 // indirect
1623
)

go.sum

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,38 @@
1+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
4+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
15
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
26
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
37
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
48
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
9+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
10+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
11+
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
12+
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
13+
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
14+
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
15+
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
16+
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
517
github.com/tinylib/msgp v1.4.0 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8=
618
github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
19+
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
20+
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
21+
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
22+
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
723
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
824
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
25+
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
26+
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
927
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
1028
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
29+
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
30+
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
1131
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
1232
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
1333
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
1434
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
35+
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
36+
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
37+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
38+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/cli/app.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,10 @@ func (a *App) CLI() *CLI {
413413
Key: "2",
414414
Val: "HTTP/2.0",
415415
},
416+
{
417+
Key: "3",
418+
Val: "HTTP/3.0",
419+
},
416420
},
417421
IsSet: func() bool {
418422
return a.Cfg.HTTP != core.HTTPDefault

internal/client/client.go

Lines changed: 114 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"io"
99
"net"
1010
"net/http"
11+
"net/http/httptrace"
1112
"net/url"
1213
"os"
1314
"strings"
@@ -19,6 +20,8 @@ import (
1920

2021
"github.com/klauspost/compress/gzip"
2122
"github.com/klauspost/compress/zstd"
23+
"github.com/quic-go/quic-go"
24+
"github.com/quic-go/quic-go/http3"
2225
"golang.org/x/net/http2"
2326
)
2427

@@ -92,42 +95,9 @@ func NewClient(cfg ClientConfig) *Client {
9295
var transport http.RoundTripper
9396
switch cfg.HTTP {
9497
case core.HTTP2:
95-
rt := &http2.Transport{
96-
AllowHTTP: false, // Disable h2c, for now.
97-
DialTLSContext: func(ctx context.Context, network string, addr string, cfg *tls.Config) (net.Conn, error) {
98-
dial := baseDial
99-
if dial == nil {
100-
var dialer net.Dialer
101-
dial = dialer.DialContext
102-
}
103-
104-
// Dial a connection and perform the TLS handshake.
105-
conn, err := dial(ctx, network, addr)
106-
if err != nil {
107-
return nil, err
108-
}
109-
110-
if cfg.ServerName == "" {
111-
c := cfg.Clone()
112-
host, _, err := net.SplitHostPort(addr)
113-
if err != nil {
114-
host = addr
115-
}
116-
c.ServerName = host
117-
cfg = c
118-
}
119-
120-
tlsConn := tls.Client(conn, cfg)
121-
if err := tlsConn.HandshakeContext(ctx); err != nil {
122-
conn.Close()
123-
return nil, err
124-
}
125-
return tlsConn, nil
126-
},
127-
DisableCompression: true,
128-
TLSClientConfig: &tlsConfig,
129-
}
130-
transport = rt
98+
transport = getHTTP2Transport(baseDial, &tlsConfig)
99+
case core.HTTP3:
100+
transport = getHTTP3Transport(cfg.DNSServer, &tlsConfig)
131101
default:
132102
rt := &http.Transport{
133103
DialContext: baseDial,
@@ -160,6 +130,114 @@ func NewClient(cfg ClientConfig) *Client {
160130
return &Client{c: client}
161131
}
162132

133+
func getHTTP2Transport(baseDial func(context.Context, string, string) (net.Conn, error), tlsConfig *tls.Config) http.RoundTripper {
134+
return &http2.Transport{
135+
AllowHTTP: false, // Disable h2c, for now.
136+
DialTLSContext: func(ctx context.Context, network string, addr string, cfg *tls.Config) (net.Conn, error) {
137+
dial := baseDial
138+
if dial == nil {
139+
var dialer net.Dialer
140+
dial = dialer.DialContext
141+
}
142+
143+
// Dial a connection and perform the TLS handshake.
144+
conn, err := dial(ctx, network, addr)
145+
if err != nil {
146+
return nil, err
147+
}
148+
149+
if cfg.ServerName == "" {
150+
c := cfg.Clone()
151+
host, _, err := net.SplitHostPort(addr)
152+
if err != nil {
153+
host = addr
154+
}
155+
c.ServerName = host
156+
cfg = c
157+
}
158+
159+
tlsConn := tls.Client(conn, cfg)
160+
if err := tlsConn.HandshakeContext(ctx); err != nil {
161+
conn.Close()
162+
return nil, err
163+
}
164+
return tlsConn, nil
165+
},
166+
DisableCompression: true,
167+
TLSClientConfig: tlsConfig,
168+
}
169+
}
170+
171+
func getHTTP3Transport(dnsServer *url.URL, tlsConfig *tls.Config) http.RoundTripper {
172+
rt := &http3.Transport{
173+
DisableCompression: true,
174+
TLSClientConfig: tlsConfig,
175+
}
176+
if dnsServer != nil {
177+
rt.Dial = func(ctx context.Context, addr string, tlsCfg *tls.Config, qcfg *quic.Config) (*quic.Conn, error) {
178+
// Resolve the address to IPs.
179+
var ips []net.IPAddr
180+
var portStr string
181+
var err error
182+
if dnsServer.Scheme == "" {
183+
var host string
184+
host, portStr, err = net.SplitHostPort(addr)
185+
if err != nil {
186+
return nil, err
187+
}
188+
resolver := udpResolver(dnsServer.Host)
189+
ips, err = resolver.LookupIPAddr(ctx, host)
190+
} else {
191+
ips, portStr, err = resolveDOH(ctx, dnsServer, addr)
192+
}
193+
if err != nil {
194+
return nil, err
195+
}
196+
if len(ips) == 0 {
197+
return nil, fmt.Errorf("lookup %s: no addresses found", addr)
198+
}
199+
200+
port, err := net.LookupPort("udp", portStr)
201+
if err != nil {
202+
return nil, err
203+
}
204+
205+
// Establish quic connection.
206+
trace := httptrace.ContextClientTrace(ctx)
207+
for _, ip := range ips {
208+
udpAddr := &net.UDPAddr{IP: ip.IP, Port: port}
209+
var lc net.ListenConfig
210+
var packetConn net.PacketConn
211+
packetConn, err = lc.ListenPacket(ctx, "udp", ":0")
212+
if err != nil {
213+
continue
214+
}
215+
216+
if trace != nil && trace.TLSHandshakeStart != nil {
217+
trace.TLSHandshakeStart()
218+
}
219+
220+
var conn *quic.Conn
221+
conn, err = quic.DialEarly(ctx, packetConn, udpAddr, tlsCfg, qcfg)
222+
if trace != nil && trace.TLSHandshakeDone != nil {
223+
var state tls.ConnectionState
224+
if conn != nil {
225+
state = conn.ConnectionState().TLS
226+
}
227+
trace.TLSHandshakeDone(state, err)
228+
}
229+
if err != nil {
230+
continue
231+
}
232+
return conn, nil
233+
}
234+
235+
return nil, err
236+
}
237+
}
238+
return rt
239+
}
240+
163241
// RequestConfig represents the configuration for creating an HTTP request.
164242
type RequestConfig struct {
165243
AWSSigV4 *aws.Config

internal/client/dns.go

Lines changed: 49 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -17,57 +17,33 @@ import (
1717
// dialContextUDP returns a DialContext function that performs a DNS lookup
1818
// using the provided DNS server address and port.
1919
func dialContextUDP(serverAddr string) func(ctx context.Context, network, address string) (net.Conn, error) {
20-
dialer := net.Dialer{
21-
Resolver: &net.Resolver{
22-
PreferGo: true,
23-
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
24-
var d net.Dialer
25-
return d.DialContext(ctx, network, serverAddr)
26-
},
20+
dialer := net.Dialer{Resolver: udpResolver(serverAddr)}
21+
return dialer.DialContext
22+
}
23+
24+
func udpResolver(serverAddr string) *net.Resolver {
25+
return &net.Resolver{
26+
PreferGo: true,
27+
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
28+
var d net.Dialer
29+
return d.DialContext(ctx, network, serverAddr)
2730
},
2831
}
29-
return dialer.DialContext
3032
}
3133

3234
// dialContextDOH returns a DialContext function that performs a DoH lookup
3335
// using the provided DoH server address.
3436
func dialContextDOH(serverURL *url.URL) func(ctx context.Context, network, address string) (net.Conn, error) {
3537
return func(ctx context.Context, network, address string) (net.Conn, error) {
36-
host, port, err := net.SplitHostPort(address)
38+
ips, port, err := resolveDOH(ctx, serverURL, address)
3739
if err != nil {
3840
return nil, err
3941
}
4042

41-
trace := httptrace.ContextClientTrace(ctx)
42-
if trace != nil && trace.DNSStart != nil {
43-
trace.DNSStart(httptrace.DNSStartInfo{Host: host})
44-
}
45-
46-
// Lookup A record first, fallback to AAAA.
47-
ips, err := lookupDOH(ctx, serverURL, host, "A")
48-
if err != nil {
49-
ips, err = lookupDOH(ctx, serverURL, host, "AAAA")
50-
}
51-
52-
if trace != nil && trace.DNSDone != nil {
53-
info := httptrace.DNSDoneInfo{Err: err}
54-
if err == nil {
55-
for _, ip := range ips {
56-
addr := net.IPAddr{IP: net.ParseIP(ip)}
57-
info.Addrs = append(info.Addrs, addr)
58-
}
59-
}
60-
trace.DNSDone(info)
61-
}
62-
63-
if err != nil {
64-
return nil, fmt.Errorf("lookup %s: %w", host, err)
65-
}
66-
6743
var d net.Dialer
6844
for _, ip := range ips {
6945
var conn net.Conn
70-
conn, err = d.DialContext(ctx, network, net.JoinHostPort(ip, port))
46+
conn, err = d.DialContext(ctx, network, net.JoinHostPort(ip.IP.String(), port))
7147
if err == nil {
7248
return conn, nil
7349
}
@@ -76,6 +52,43 @@ func dialContextDOH(serverURL *url.URL) func(ctx context.Context, network, addre
7652
}
7753
}
7854

55+
func resolveDOH(ctx context.Context, serverURL *url.URL, address string) ([]net.IPAddr, string, error) {
56+
host, port, err := net.SplitHostPort(address)
57+
if err != nil {
58+
return nil, "", err
59+
}
60+
61+
trace := httptrace.ContextClientTrace(ctx)
62+
if trace != nil && trace.DNSStart != nil {
63+
trace.DNSStart(httptrace.DNSStartInfo{Host: host})
64+
}
65+
66+
// Lookup A record first, fallback to AAAA.
67+
ipStrs, err := lookupDOH(ctx, serverURL, host, "A")
68+
if err != nil {
69+
ipStrs, err = lookupDOH(ctx, serverURL, host, "AAAA")
70+
}
71+
72+
ips := make([]net.IPAddr, 0, len(ipStrs))
73+
for _, ip := range ipStrs {
74+
ips = append(ips, net.IPAddr{IP: net.ParseIP(ip)})
75+
}
76+
77+
if trace != nil && trace.DNSDone != nil {
78+
info := httptrace.DNSDoneInfo{Err: err}
79+
if err == nil {
80+
info.Addrs = append(info.Addrs, ips...)
81+
}
82+
trace.DNSDone(info)
83+
}
84+
85+
if err != nil {
86+
return nil, "", fmt.Errorf("lookup %s: %w", host, err)
87+
}
88+
89+
return ips, port, nil
90+
}
91+
7992
// lookupDOH performs a DNS lookup via DoH with the provided DoH server URL,
8093
// host to lookup, and DNS type.
8194
func lookupDOH(ctx context.Context, serverURL *url.URL, host, dnsType string) ([]string, error) {

internal/config/config.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,8 +277,10 @@ func (c *Config) ParseHTTP(value string) error {
277277
c.HTTP = core.HTTP1
278278
case "2":
279279
c.HTTP = core.HTTP2
280+
case "3":
281+
c.HTTP = core.HTTP3
280282
default:
281-
const usage = "must be one of [1, 2]"
283+
const usage = "must be one of [1, 2, 3]"
282284
return core.NewValueError("http", value, usage, c.isFile)
283285
}
284286
return nil

0 commit comments

Comments
 (0)