Skip to content

Commit 9359174

Browse files
authored
Merge pull request #9 from getlantern/fisk/h2-fronting
roundtrip: support HTTP/2 fronting via ALPN-aware transport
2 parents fdc839b + dd16670 commit 9359174

6 files changed

Lines changed: 399 additions & 26 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ A clean, production-grade domain fronting library for Go.
1313
- **Atomic config updates**`FrontPool.Replace()` swaps the candidate set without unbounded growth, preserving state from previously-working fronts
1414
- **Smart round-trip** — checks provider host mapping *before* dialing TLS, avoiding wasted connections
1515
- **TLS fingerprinting** — uses [utls](https://github.com/refraction-networking/utls) to mimic real browser Client Hellos (Chrome 131 by default)
16+
- **HTTP/1.1 and HTTP/2** — the fronted request is framed to match whichever protocol the edge selects via ALPN, so the genuine browser ALPN (`h2,http/1.1`) can be offered without breaking against h2-only routing (CloudFront, Aliyun); routing works identically since the fronted host drives the `Host` header or the HTTP/2 `:authority` pseudo-header
1617
- **Country-aware SNI** — deterministic SNI selection from per-country lists, derived from IP hash
1718
- **Persistent caching** — working fronts are cached to disk (JSON) for fast startup
1819
- **Auto-updating config** — optionally fetches updated `fronted.yaml.gz` from a URL every 12 hours
19-
- **Minimal dependencies**only `utls` and `go-yaml`; no worker pool libraries, no logging frameworks, no custom HTTP fetchers
20+
- **Minimal dependencies**`utls`, `go-yaml`, and `golang.org/x/net/http2`; no worker pool libraries, no logging frameworks, no custom HTTP fetchers
2021
- **Fully testable**`Dialer` and `Cache` interfaces; unit tests use pipe-based mock TLS servers, no real CDN infrastructure required
2122

2223
## Installation

domainfront.go

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,14 @@ type Client struct {
6666
// Option configures a Client.
6767
type Option func(*Client)
6868

69-
func WithLogger(l *slog.Logger) Option { return func(c *Client) { c.log = l } }
70-
func WithDialer(d Dialer) Option { return func(c *Client) { c.dialer = d } }
71-
func WithCountryCode(cc string) Option { return func(c *Client) { c.countryCode = cc } }
72-
func WithDefaultProviderID(id string) Option { return func(c *Client) { c.defaultPID = id } }
73-
func WithConfigURL(url string) Option { return func(c *Client) { c.configURL = url } }
74-
func WithHTTPClient(hc *http.Client) Option { return func(c *Client) { c.httpClient = hc } }
75-
func WithCache(cache Cache) Option { return func(c *Client) { c.cache = cache } }
76-
func WithMaxRetries(n int) Option { return func(c *Client) { c.maxRetries = n } }
69+
func WithLogger(l *slog.Logger) Option { return func(c *Client) { c.log = l } }
70+
func WithDialer(d Dialer) Option { return func(c *Client) { c.dialer = d } }
71+
func WithCountryCode(cc string) Option { return func(c *Client) { c.countryCode = cc } }
72+
func WithDefaultProviderID(id string) Option { return func(c *Client) { c.defaultPID = id } }
73+
func WithConfigURL(url string) Option { return func(c *Client) { c.configURL = url } }
74+
func WithHTTPClient(hc *http.Client) Option { return func(c *Client) { c.httpClient = hc } }
75+
func WithCache(cache Cache) Option { return func(c *Client) { c.cache = cache } }
76+
func WithMaxRetries(n int) Option { return func(c *Client) { c.maxRetries = n } }
7777
func WithClientHelloID(id tls.ClientHelloID) Option {
7878
return func(c *Client) { c.clientHelloID = id }
7979
}
@@ -328,15 +328,15 @@ func (c *Client) vetFront(f *front) bool {
328328
var vetBody = []byte("a")
329329

330330
func (c *Client) verifyWithPost(conn net.Conn, testURL string) bool {
331-
tr := newConnTransport(conn, true)
332331
req, err := http.NewRequest(http.MethodPost, testURL, bytes.NewReader(vetBody))
333332
if err != nil {
334333
c.log.Debug("Error creating vet request", "error", err)
335334
return false
336335
}
337-
req.URL.Scheme = "http" // TLS already established on conn
338336
req.Header.Set("Content-Type", "application/json")
339-
resp, err := tr.RoundTrip(req)
337+
// Frame the vet request to match the negotiated ALPN — the dialed front
338+
// may have negotiated h2, exactly like real request traffic.
339+
resp, err := sendOverConn(conn, req, true)
340340
if err != nil {
341341
c.log.Debug("Error vetting front", "error", err, "url", testURL)
342342
return false

go.mod

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,18 @@ toolchain go1.24.1
66

77
require (
88
github.com/goccy/go-yaml v1.15.13
9-
github.com/refraction-networking/utls v1.7.1
9+
github.com/refraction-networking/utls v1.8.2
1010
github.com/stretchr/testify v1.11.1
11+
golang.org/x/net v0.38.0
1112
)
1213

1314
require (
1415
github.com/andybalholm/brotli v1.0.6 // indirect
15-
github.com/cloudflare/circl v1.5.0 // indirect
1616
github.com/davecgh/go-spew v1.1.1 // indirect
1717
github.com/klauspost/compress v1.17.4 // indirect
1818
github.com/pmezard/go-difflib v1.0.0 // indirect
1919
golang.org/x/crypto v0.36.0 // indirect
2020
golang.org/x/sys v0.31.0 // indirect
21+
golang.org/x/text v0.23.0 // indirect
2122
gopkg.in/yaml.v3 v3.0.1 // indirect
2223
)

go.sum

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
22
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
3-
github.com/cloudflare/circl v1.5.0 h1:hxIWksrX6XN5a1L2TI/h53AGPhNHoUBo+TD1ms9+pys=
4-
github.com/cloudflare/circl v1.5.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
53
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
64
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
75
github.com/goccy/go-yaml v1.15.13 h1:Xd87Yddmr2rC1SLLTm2MNDcTjeO/GYo0JGiww6gSTDg=
@@ -10,14 +8,18 @@ github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW
108
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
119
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
1210
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
13-
github.com/refraction-networking/utls v1.7.1 h1:dxg+jla3uocgN8HtX+ccwDr68uCBBO3qLrkZUbqkcw0=
14-
github.com/refraction-networking/utls v1.7.1/go.mod h1:TUhh27RHMGtQvjQq+RyO11P6ZNQNBb3N0v7wsEjKAIQ=
11+
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
12+
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
1513
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
1614
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
1715
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
1816
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
17+
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
18+
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
1919
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
2020
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
21+
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
22+
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
2123
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
2224
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
2325
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

h2_test.go

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
package domainfront
2+
3+
import (
4+
"context"
5+
stdtls "crypto/tls"
6+
"crypto/x509"
7+
"errors"
8+
"io"
9+
"log/slog"
10+
"net"
11+
"net/http"
12+
"testing"
13+
"time"
14+
15+
utls "github.com/refraction-networking/utls"
16+
"github.com/stretchr/testify/assert"
17+
"github.com/stretchr/testify/require"
18+
"golang.org/x/net/http2"
19+
)
20+
21+
// dialPipeH2 stands up an HTTP/2 server on one end of a net.Pipe (TLS with
22+
// ALPN "h2") and returns a handshaken utls client connection to it — the same
23+
// connection type dialFront produces in production. No real network is used.
24+
func dialPipeH2(t *testing.T, handler http.Handler) *utls.UConn {
25+
t.Helper()
26+
ca, caKey := newTestCA(t)
27+
leaf := newTestLeafCert(t, ca, caKey, "cdn.example.com")
28+
roots := x509.NewCertPool()
29+
roots.AddCert(ca)
30+
31+
// Bound both handshakes so a broken pairing fails fast on the deadline
32+
// rather than hanging until the `go test` timeout. net.Pipe honors
33+
// deadlines, so HandshakeContext can interrupt a stalled handshake.
34+
// Cancel at test end (not on return): the returned conn is used after this
35+
// helper returns, and cancelling the handshake context while the conn is
36+
// still in use poisons it (sets a past deadline) on some Go versions.
37+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
38+
t.Cleanup(cancel)
39+
40+
clientRaw, serverRaw := net.Pipe()
41+
go func() {
42+
srv := stdtls.Server(serverRaw, &stdtls.Config{
43+
Certificates: []stdtls.Certificate{leaf},
44+
NextProtos: []string{"h2"},
45+
})
46+
if err := srv.HandshakeContext(ctx); err != nil {
47+
serverRaw.Close()
48+
return
49+
}
50+
(&http2.Server{}).ServeConn(srv, &http2.ServeConnOpts{Handler: handler})
51+
}()
52+
53+
uconn := utls.UClient(clientRaw, &utls.Config{
54+
RootCAs: roots,
55+
ServerName: "cdn.example.com",
56+
NextProtos: []string{"h2"},
57+
}, utls.HelloGolang)
58+
require.NoError(t, uconn.HandshakeContext(ctx))
59+
require.Equal(t, "h2", negotiatedProtocol(uconn), "handshake should negotiate h2")
60+
// Close the conn at test end so the server goroutine's ServeConn unblocks
61+
// (it otherwise reads the pipe until close). Tests that complete a request
62+
// already tear the conn down via resp.Body.Close; this also covers tests
63+
// that never send one (e.g. an upgrade rejected before any round-trip).
64+
t.Cleanup(func() { _ = uconn.Close(); _ = serverRaw.Close() })
65+
return uconn
66+
}
67+
68+
// TestDoRequest_HTTP2 verifies that when the TLS handshake negotiates h2,
69+
// doRequest frames the fronted request as HTTP/2 and the fronted host lands in
70+
// the :authority pseudo-header (the h2 equivalent of the Host header that drives
71+
// CDN routing).
72+
func TestDoRequest_HTTP2(t *testing.T) {
73+
type observed struct {
74+
authority, path, hdr string
75+
proto int
76+
}
77+
seen := make(chan observed, 1)
78+
conn := dialPipeH2(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
79+
seen <- observed{r.Host, r.URL.Path, r.Header.Get("X-Probe"), r.ProtoMajor}
80+
w.WriteHeader(http.StatusOK)
81+
io.WriteString(w, "h2-fronted-ok")
82+
}))
83+
84+
req, err := http.NewRequest(http.MethodGet, "https://config.example.com/api/data", nil)
85+
require.NoError(t, err)
86+
req.Header.Set("X-Probe", "carried")
87+
88+
resp, err := (&roundTripper{}).doRequest(req, conn, "cdn.example.com", nil)
89+
require.NoError(t, err)
90+
91+
body, err := io.ReadAll(resp.Body)
92+
require.NoError(t, err)
93+
require.NoError(t, resp.Body.Close()) // tears down the h2 conn
94+
95+
assert.Equal(t, http.StatusOK, resp.StatusCode)
96+
assert.Equal(t, 2, resp.ProtoMajor, "response should be HTTP/2")
97+
assert.Equal(t, "h2-fronted-ok", string(body))
98+
99+
got := <-seen
100+
assert.Equal(t, "cdn.example.com", got.authority, ":authority must be the fronted host, not the origin")
101+
assert.Equal(t, "/api/data", got.path, "path must be preserved from the original request")
102+
assert.Equal(t, 2, got.proto, "server must see an HTTP/2 request")
103+
assert.Equal(t, "carried", got.hdr, "caller headers must propagate")
104+
}
105+
106+
// TestVerifyWithPost_HTTP2 covers the front-vetting path over an h2 connection.
107+
// Vetting gates whether a front becomes usable, so it must speak h2 just like
108+
// request traffic — otherwise every h2 edge (CloudFront, Aliyun) fails to vet.
109+
func TestVerifyWithPost_HTTP2(t *testing.T) {
110+
gotMethod := make(chan string, 1)
111+
conn := dialPipeH2(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
112+
gotMethod <- r.Method
113+
w.WriteHeader(http.StatusAccepted) // 202: the contract verifyWithPost checks
114+
}))
115+
116+
c := &Client{log: slog.Default()}
117+
ok := c.verifyWithPost(conn, "https://cdn.example.com/ping")
118+
119+
assert.True(t, ok, "a 202 over h2 should vet successfully")
120+
assert.Equal(t, http.MethodPost, <-gotMethod)
121+
}
122+
123+
// TestStripConnHeaders verifies the RFC 7540 §8.1.2.2 transformation: the
124+
// connection-specific headers and any header named in Connection are removed,
125+
// while ordinary headers are preserved.
126+
func TestStripConnHeaders(t *testing.T) {
127+
req, err := http.NewRequest(http.MethodGet, "https://x/y", nil)
128+
require.NoError(t, err)
129+
req.Header.Set("Connection", "keep-alive, X-Hop")
130+
req.Header.Set("X-Hop", "drop") // named in Connection
131+
req.Header.Set("Keep-Alive", "timeout=5")
132+
req.Header.Set("Transfer-Encoding", "chunked")
133+
req.Header.Set("Upgrade", "h2c")
134+
req.Header.Set("X-Keep", "stay")
135+
136+
stripConnHeaders(req)
137+
138+
for _, h := range []string{"Connection", "X-Hop", "Keep-Alive", "Transfer-Encoding", "Upgrade"} {
139+
assert.Emptyf(t, req.Header.Get(h), "%s must be stripped", h)
140+
}
141+
assert.Equal(t, "stay", req.Header.Get("X-Keep"), "non-connection headers must remain")
142+
}
143+
144+
// TestSendOverConn_H2_RejectsUpgrade verifies an upgrade request over an h2
145+
// front is rejected with errH2UpgradeUnsupported (so RoundTrip can retry onto
146+
// an http/1.1 front) and never reaches the server.
147+
func TestSendOverConn_H2_RejectsUpgrade(t *testing.T) {
148+
reached := make(chan struct{}, 1)
149+
conn := dialPipeH2(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
150+
reached <- struct{}{}
151+
w.WriteHeader(http.StatusOK)
152+
}))
153+
req, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/ws", nil)
154+
require.NoError(t, err)
155+
req.Header.Set("Connection", "Upgrade")
156+
req.Header.Set("Upgrade", "websocket")
157+
158+
_, err = sendOverConn(conn, req, false)
159+
require.ErrorIs(t, err, errH2UpgradeUnsupported)
160+
select {
161+
case <-reached:
162+
t.Fatal("server must not be reached for an upgrade rejected over h2")
163+
default:
164+
}
165+
}
166+
167+
// TestSendOverConn_H2_StripsForbiddenHeaders verifies that connection-specific
168+
// headers (which x/net/http2 would otherwise reject) are stripped before h2
169+
// framing, so an otherwise-valid request still succeeds.
170+
func TestSendOverConn_H2_StripsForbiddenHeaders(t *testing.T) {
171+
gotHop := make(chan string, 1)
172+
conn := dialPipeH2(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
173+
gotHop <- r.Header.Get("X-Hop")
174+
w.WriteHeader(http.StatusOK)
175+
}))
176+
req, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/x", nil)
177+
require.NoError(t, err)
178+
// Transfer-Encoding: gzip and a non-close/keep-alive Connection token both
179+
// make x/net/http2 reject the request unless stripped first.
180+
req.Header.Set("Transfer-Encoding", "gzip")
181+
req.Header.Set("Connection", "X-Hop")
182+
req.Header.Set("X-Hop", "should-be-stripped")
183+
184+
resp, err := sendOverConn(conn, req, true)
185+
require.NoError(t, err)
186+
require.NoError(t, resp.Body.Close())
187+
assert.Equal(t, http.StatusOK, resp.StatusCode)
188+
assert.Empty(t, <-gotHop, "Connection-named header must be stripped before h2")
189+
}
190+
191+
// errCloser is a test io.Closer that records call count and returns a fixed err.
192+
type errCloser struct {
193+
err error
194+
calls int
195+
}
196+
197+
func (c *errCloser) Close() error { c.calls++; return c.err }
198+
199+
// TestH2Body_Close covers the connection-teardown semantics: the underlying h2
200+
// connection is closed exactly once, its error surfaces when the body close
201+
// itself succeeds, and a body-close error takes precedence.
202+
func TestH2Body_Close(t *testing.T) {
203+
t.Run("propagates cc error when body close ok", func(t *testing.T) {
204+
cc := &errCloser{err: assert.AnError}
205+
b := &h2Body{ReadCloser: io.NopCloser(nil), cc: cc}
206+
assert.Equal(t, assert.AnError, b.Close())
207+
assert.Equal(t, 1, cc.calls)
208+
})
209+
210+
t.Run("body error takes precedence over cc error", func(t *testing.T) {
211+
bodyErr := errors.New("body boom")
212+
cc := &errCloser{err: assert.AnError}
213+
b := &h2Body{ReadCloser: errReadCloser{bodyErr}, cc: cc}
214+
assert.Equal(t, bodyErr, b.Close())
215+
assert.Equal(t, 1, cc.calls, "cc still closed even when body close fails")
216+
})
217+
218+
t.Run("closes the connection only once", func(t *testing.T) {
219+
cc := &errCloser{}
220+
b := &h2Body{ReadCloser: io.NopCloser(nil), cc: cc}
221+
assert.NoError(t, b.Close())
222+
assert.NoError(t, b.Close())
223+
assert.Equal(t, 1, cc.calls)
224+
})
225+
}
226+
227+
type errReadCloser struct{ err error }
228+
229+
func (e errReadCloser) Read([]byte) (int, error) { return 0, e.err }
230+
func (e errReadCloser) Close() error { return e.err }
231+
232+
func TestHasConnectionUpgrade(t *testing.T) {
233+
mk := func(vals ...string) *http.Request {
234+
r, _ := http.NewRequest(http.MethodGet, "https://x/", nil)
235+
for _, v := range vals {
236+
r.Header.Add("Connection", v)
237+
}
238+
return r
239+
}
240+
assert.True(t, hasConnectionUpgrade(mk("upgrade")))
241+
assert.True(t, hasConnectionUpgrade(mk("keep-alive, Upgrade")), "token in a comma list")
242+
assert.True(t, hasConnectionUpgrade(mk("keep-alive", "Upgrade")), "multiple header values")
243+
assert.False(t, hasConnectionUpgrade(mk("keep-alive")))
244+
assert.False(t, hasConnectionUpgrade(mk()))
245+
}

0 commit comments

Comments
 (0)