Skip to content
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ A clean, production-grade domain fronting library for Go.
- **Atomic config updates** — `FrontPool.Replace()` swaps the candidate set without unbounded growth, preserving state from previously-working fronts
- **Smart round-trip** — checks provider host mapping *before* dialing TLS, avoiding wasted connections
- **TLS fingerprinting** — uses [utls](https://github.com/refraction-networking/utls) to mimic real browser Client Hellos (Chrome 131 by default)
- **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
- **Country-aware SNI** — deterministic SNI selection from per-country lists, derived from IP hash
- **Persistent caching** — working fronts are cached to disk (JSON) for fast startup
- **Auto-updating config** — optionally fetches updated `fronted.yaml.gz` from a URL every 12 hours
- **Minimal dependencies** — only `utls` and `go-yaml`; no worker pool libraries, no logging frameworks, no custom HTTP fetchers
- **Minimal dependencies** — `utls`, `go-yaml`, and `golang.org/x/net/http2`; no worker pool libraries, no logging frameworks, no custom HTTP fetchers
- **Fully testable** — `Dialer` and `Cache` interfaces; unit tests use pipe-based mock TLS servers, no real CDN infrastructure required

## Installation
Expand Down
22 changes: 11 additions & 11 deletions domainfront.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,14 @@ type Client struct {
// Option configures a Client.
type Option func(*Client)

func WithLogger(l *slog.Logger) Option { return func(c *Client) { c.log = l } }
func WithDialer(d Dialer) Option { return func(c *Client) { c.dialer = d } }
func WithCountryCode(cc string) Option { return func(c *Client) { c.countryCode = cc } }
func WithDefaultProviderID(id string) Option { return func(c *Client) { c.defaultPID = id } }
func WithConfigURL(url string) Option { return func(c *Client) { c.configURL = url } }
func WithHTTPClient(hc *http.Client) Option { return func(c *Client) { c.httpClient = hc } }
func WithCache(cache Cache) Option { return func(c *Client) { c.cache = cache } }
func WithMaxRetries(n int) Option { return func(c *Client) { c.maxRetries = n } }
func WithLogger(l *slog.Logger) Option { return func(c *Client) { c.log = l } }
func WithDialer(d Dialer) Option { return func(c *Client) { c.dialer = d } }
func WithCountryCode(cc string) Option { return func(c *Client) { c.countryCode = cc } }
func WithDefaultProviderID(id string) Option { return func(c *Client) { c.defaultPID = id } }
func WithConfigURL(url string) Option { return func(c *Client) { c.configURL = url } }
func WithHTTPClient(hc *http.Client) Option { return func(c *Client) { c.httpClient = hc } }
func WithCache(cache Cache) Option { return func(c *Client) { c.cache = cache } }
func WithMaxRetries(n int) Option { return func(c *Client) { c.maxRetries = n } }
func WithClientHelloID(id tls.ClientHelloID) Option {
return func(c *Client) { c.clientHelloID = id }
}
Expand Down Expand Up @@ -328,15 +328,15 @@ func (c *Client) vetFront(f *front) bool {
var vetBody = []byte("a")

func (c *Client) verifyWithPost(conn net.Conn, testURL string) bool {
tr := newConnTransport(conn, true)
req, err := http.NewRequest(http.MethodPost, testURL, bytes.NewReader(vetBody))
if err != nil {
c.log.Debug("Error creating vet request", "error", err)
return false
}
req.URL.Scheme = "http" // TLS already established on conn
req.Header.Set("Content-Type", "application/json")
resp, err := tr.RoundTrip(req)
// Frame the vet request to match the negotiated ALPN — the dialed front
// may have negotiated h2, exactly like real request traffic.
resp, err := sendOverConn(conn, req, true)
if err != nil {
c.log.Debug("Error vetting front", "error", err, "url", testURL)
return false
Expand Down
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,18 @@ toolchain go1.24.1

require (
github.com/goccy/go-yaml v1.15.13
github.com/refraction-networking/utls v1.7.1
github.com/refraction-networking/utls v1.8.2
github.com/stretchr/testify v1.11.1
golang.org/x/net v0.38.0
)

require (
github.com/andybalholm/brotli v1.0.6 // indirect
github.com/cloudflare/circl v1.5.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/klauspost/compress v1.17.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
10 changes: 6 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/cloudflare/circl v1.5.0 h1:hxIWksrX6XN5a1L2TI/h53AGPhNHoUBo+TD1ms9+pys=
github.com/cloudflare/circl v1.5.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/goccy/go-yaml v1.15.13 h1:Xd87Yddmr2rC1SLLTm2MNDcTjeO/GYo0JGiww6gSTDg=
Expand All @@ -10,14 +8,18 @@ github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/refraction-networking/utls v1.7.1 h1:dxg+jla3uocgN8HtX+ccwDr68uCBBO3qLrkZUbqkcw0=
github.com/refraction-networking/utls v1.7.1/go.mod h1:TUhh27RHMGtQvjQq+RyO11P6ZNQNBb3N0v7wsEjKAIQ=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
Expand Down
245 changes: 245 additions & 0 deletions h2_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
package domainfront

import (
"context"
stdtls "crypto/tls"
"crypto/x509"
"errors"
"io"
"log/slog"
"net"
"net/http"
"testing"
"time"

utls "github.com/refraction-networking/utls"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/net/http2"
)

// dialPipeH2 stands up an HTTP/2 server on one end of a net.Pipe (TLS with
// ALPN "h2") and returns a handshaken utls client connection to it — the same
// connection type dialFront produces in production. No real network is used.
func dialPipeH2(t *testing.T, handler http.Handler) *utls.UConn {
t.Helper()
ca, caKey := newTestCA(t)
leaf := newTestLeafCert(t, ca, caKey, "cdn.example.com")
roots := x509.NewCertPool()
roots.AddCert(ca)

// Bound both handshakes so a broken pairing fails fast on the deadline
// rather than hanging until the `go test` timeout. net.Pipe honors
// deadlines, so HandshakeContext can interrupt a stalled handshake.
// Cancel at test end (not on return): the returned conn is used after this
// helper returns, and cancelling the handshake context while the conn is
// still in use poisons it (sets a past deadline) on some Go versions.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
t.Cleanup(cancel)

clientRaw, serverRaw := net.Pipe()
go func() {
srv := stdtls.Server(serverRaw, &stdtls.Config{
Certificates: []stdtls.Certificate{leaf},
NextProtos: []string{"h2"},
})
if err := srv.HandshakeContext(ctx); err != nil {
serverRaw.Close()
return
}
(&http2.Server{}).ServeConn(srv, &http2.ServeConnOpts{Handler: handler})
}()

uconn := utls.UClient(clientRaw, &utls.Config{
RootCAs: roots,
ServerName: "cdn.example.com",
NextProtos: []string{"h2"},
}, utls.HelloGolang)
require.NoError(t, uconn.HandshakeContext(ctx))
require.Equal(t, "h2", negotiatedProtocol(uconn), "handshake should negotiate h2")
// Close the conn at test end so the server goroutine's ServeConn unblocks
// (it otherwise reads the pipe until close). Tests that complete a request
// already tear the conn down via resp.Body.Close; this also covers tests
// that never send one (e.g. an upgrade rejected before any round-trip).
t.Cleanup(func() { _ = uconn.Close(); _ = serverRaw.Close() })
return uconn
}

// TestDoRequest_HTTP2 verifies that when the TLS handshake negotiates h2,
// doRequest frames the fronted request as HTTP/2 and the fronted host lands in
// the :authority pseudo-header (the h2 equivalent of the Host header that drives
// CDN routing).
func TestDoRequest_HTTP2(t *testing.T) {
type observed struct {
authority, path, hdr string
proto int
}
seen := make(chan observed, 1)
conn := dialPipeH2(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen <- observed{r.Host, r.URL.Path, r.Header.Get("X-Probe"), r.ProtoMajor}
w.WriteHeader(http.StatusOK)
io.WriteString(w, "h2-fronted-ok")
}))

req, err := http.NewRequest(http.MethodGet, "https://config.example.com/api/data", nil)
require.NoError(t, err)
req.Header.Set("X-Probe", "carried")

resp, err := (&roundTripper{}).doRequest(req, conn, "cdn.example.com", nil)
require.NoError(t, err)

body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close()) // tears down the h2 conn

assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, 2, resp.ProtoMajor, "response should be HTTP/2")
assert.Equal(t, "h2-fronted-ok", string(body))

got := <-seen
assert.Equal(t, "cdn.example.com", got.authority, ":authority must be the fronted host, not the origin")
assert.Equal(t, "/api/data", got.path, "path must be preserved from the original request")
assert.Equal(t, 2, got.proto, "server must see an HTTP/2 request")
assert.Equal(t, "carried", got.hdr, "caller headers must propagate")
}

// TestVerifyWithPost_HTTP2 covers the front-vetting path over an h2 connection.
// Vetting gates whether a front becomes usable, so it must speak h2 just like
// request traffic — otherwise every h2 edge (CloudFront, Aliyun) fails to vet.
func TestVerifyWithPost_HTTP2(t *testing.T) {
gotMethod := make(chan string, 1)
conn := dialPipeH2(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod <- r.Method
w.WriteHeader(http.StatusAccepted) // 202: the contract verifyWithPost checks
}))

c := &Client{log: slog.Default()}
ok := c.verifyWithPost(conn, "https://cdn.example.com/ping")

assert.True(t, ok, "a 202 over h2 should vet successfully")
assert.Equal(t, http.MethodPost, <-gotMethod)
}

// TestStripConnHeaders verifies the RFC 7540 §8.1.2.2 transformation: the
// connection-specific headers and any header named in Connection are removed,
// while ordinary headers are preserved.
func TestStripConnHeaders(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "https://x/y", nil)
require.NoError(t, err)
req.Header.Set("Connection", "keep-alive, X-Hop")
req.Header.Set("X-Hop", "drop") // named in Connection
req.Header.Set("Keep-Alive", "timeout=5")
req.Header.Set("Transfer-Encoding", "chunked")
req.Header.Set("Upgrade", "h2c")
req.Header.Set("X-Keep", "stay")

stripConnHeaders(req)

for _, h := range []string{"Connection", "X-Hop", "Keep-Alive", "Transfer-Encoding", "Upgrade"} {
assert.Emptyf(t, req.Header.Get(h), "%s must be stripped", h)
}
assert.Equal(t, "stay", req.Header.Get("X-Keep"), "non-connection headers must remain")
}

// TestSendOverConn_H2_RejectsUpgrade verifies an upgrade request over an h2
// front is rejected with errH2UpgradeUnsupported (so RoundTrip can retry onto
// an http/1.1 front) and never reaches the server.
func TestSendOverConn_H2_RejectsUpgrade(t *testing.T) {
reached := make(chan struct{}, 1)
conn := dialPipeH2(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached <- struct{}{}
w.WriteHeader(http.StatusOK)
}))
req, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/ws", nil)
Comment thread
myleshorton marked this conversation as resolved.
require.NoError(t, err)
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "websocket")

_, err = sendOverConn(conn, req, false)
require.ErrorIs(t, err, errH2UpgradeUnsupported)
select {
case <-reached:
t.Fatal("server must not be reached for an upgrade rejected over h2")
default:
}
}

// TestSendOverConn_H2_StripsForbiddenHeaders verifies that connection-specific
// headers (which x/net/http2 would otherwise reject) are stripped before h2
// framing, so an otherwise-valid request still succeeds.
func TestSendOverConn_H2_StripsForbiddenHeaders(t *testing.T) {
gotHop := make(chan string, 1)
conn := dialPipeH2(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotHop <- r.Header.Get("X-Hop")
w.WriteHeader(http.StatusOK)
}))
req, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/x", nil)
require.NoError(t, err)
// Transfer-Encoding: gzip and a non-close/keep-alive Connection token both
// make x/net/http2 reject the request unless stripped first.
req.Header.Set("Transfer-Encoding", "gzip")
req.Header.Set("Connection", "X-Hop")
req.Header.Set("X-Hop", "should-be-stripped")

resp, err := sendOverConn(conn, req, true)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Empty(t, <-gotHop, "Connection-named header must be stripped before h2")
}

// errCloser is a test io.Closer that records call count and returns a fixed err.
type errCloser struct {
err error
calls int
}

func (c *errCloser) Close() error { c.calls++; return c.err }

// TestH2Body_Close covers the connection-teardown semantics: the underlying h2
// connection is closed exactly once, its error surfaces when the body close
// itself succeeds, and a body-close error takes precedence.
func TestH2Body_Close(t *testing.T) {
t.Run("propagates cc error when body close ok", func(t *testing.T) {
cc := &errCloser{err: assert.AnError}
b := &h2Body{ReadCloser: io.NopCloser(nil), cc: cc}
assert.Equal(t, assert.AnError, b.Close())
assert.Equal(t, 1, cc.calls)
})

t.Run("body error takes precedence over cc error", func(t *testing.T) {
bodyErr := errors.New("body boom")
cc := &errCloser{err: assert.AnError}
b := &h2Body{ReadCloser: errReadCloser{bodyErr}, cc: cc}
assert.Equal(t, bodyErr, b.Close())
assert.Equal(t, 1, cc.calls, "cc still closed even when body close fails")
})

t.Run("closes the connection only once", func(t *testing.T) {
cc := &errCloser{}
b := &h2Body{ReadCloser: io.NopCloser(nil), cc: cc}
assert.NoError(t, b.Close())
assert.NoError(t, b.Close())
assert.Equal(t, 1, cc.calls)
})
}

type errReadCloser struct{ err error }

func (e errReadCloser) Read([]byte) (int, error) { return 0, e.err }
func (e errReadCloser) Close() error { return e.err }

func TestHasConnectionUpgrade(t *testing.T) {
mk := func(vals ...string) *http.Request {
r, _ := http.NewRequest(http.MethodGet, "https://x/", nil)
for _, v := range vals {
r.Header.Add("Connection", v)
}
return r
}
assert.True(t, hasConnectionUpgrade(mk("upgrade")))
assert.True(t, hasConnectionUpgrade(mk("keep-alive, Upgrade")), "token in a comma list")
assert.True(t, hasConnectionUpgrade(mk("keep-alive", "Upgrade")), "multiple header values")
assert.False(t, hasConnectionUpgrade(mk("keep-alive")))
assert.False(t, hasConnectionUpgrade(mk()))
}
Loading
Loading