Files
sing-box/common/httpclient/apple_transport_darwin_test.go
2026-07-25 22:58:38 +08:00

955 lines
28 KiB
Go

//go:build darwin && cgo
package httpclient
import (
"bytes"
"context"
"crypto/sha256"
stdtls "crypto/tls"
"crypto/x509"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strconv"
"strings"
"testing"
"time"
"unsafe"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/proxybridge"
boxTLS "github.com/sagernet/sing-box/common/tls"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-box/route"
"github.com/sagernet/sing/common/json/badoption"
commonLogger "github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/service"
)
const appleHTTPTestTimeout = 5 * time.Second
const appleHTTPRecoveryLoops = 5
type appleHTTPTestDialer struct {
dialer net.Dialer
listener net.ListenConfig
hostMap map[string]string
}
type appleHTTPObservedRequest struct {
method string
body string
host string
values []string
protoMajor int
}
type appleHTTPTestServer struct {
server *httptest.Server
baseURL string
dialHost string
certificate stdtls.Certificate
certificatePEM string
publicKeyHash []byte
}
type appleTestAnchors struct {
ref unsafe.Pointer
releases int
}
func (a *appleTestAnchors) Retain() adapter.AppleAnchors {
return a
}
func (a *appleTestAnchors) Release() {
a.releases++
}
func (a *appleTestAnchors) Ref() unsafe.Pointer {
return a.ref
}
func TestNewAppleSessionConfig(t *testing.T) {
serverCertificate, serverCertificatePEM := newAppleHTTPTestCertificate(t, "localhost")
serverHash := certificatePublicKeySHA256(t, serverCertificate.Certificate[0])
otherHash := bytes.Repeat([]byte{0x7f}, applePinnedHashSize)
testCases := []struct {
name string
options option.HTTPClientOptions
check func(t *testing.T, config appleSessionConfig)
wantErr string
}{
{
name: "success with certificate anchors",
options: option.HTTPClientOptions{
Version: 2,
DialerOptions: option.DialerOptions{
AbstractDialerOptions: option.AbstractDialerOptions{
ConnectTimeout: badoption.Duration(2 * time.Second),
},
},
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
Enabled: true,
ServerName: "localhost",
MinVersion: "1.2",
MaxVersion: "1.3",
Certificate: badoption.Listable[string]{serverCertificatePEM},
},
},
},
check: func(t *testing.T, config appleSessionConfig) {
t.Helper()
if config.serverName != "localhost" {
t.Fatalf("unexpected server name: %q", config.serverName)
}
if config.minVersion != stdtls.VersionTLS12 {
t.Fatalf("unexpected min version: %x", config.minVersion)
}
if config.maxVersion != stdtls.VersionTLS13 {
t.Fatalf("unexpected max version: %x", config.maxVersion)
}
if config.insecure {
t.Fatal("unexpected insecure flag")
}
if !config.anchorOnly {
t.Fatal("expected anchor_only")
}
if config.userAnchors == nil {
t.Fatal("expected user anchors")
}
if config.userAnchors.Ref() == nil {
t.Fatal("expected non-empty user anchors")
}
if config.store != nil {
t.Fatal("unexpected store reference")
}
if len(config.pinnedPublicKeySHA256s) != 0 {
t.Fatalf("unexpected pinned hashes: %d", len(config.pinnedPublicKeySHA256s))
}
},
},
{
name: "success with flattened pins",
options: option.HTTPClientOptions{
Version: 2,
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
Enabled: true,
Insecure: true,
CertificatePublicKeySHA256: badoption.Listable[[]byte]{serverHash, otherHash},
},
},
},
check: func(t *testing.T, config appleSessionConfig) {
t.Helper()
if !config.insecure {
t.Fatal("expected insecure flag")
}
if len(config.pinnedPublicKeySHA256s) != 2*applePinnedHashSize {
t.Fatalf("unexpected flattened pin length: %d", len(config.pinnedPublicKeySHA256s))
}
if !bytes.Equal(config.pinnedPublicKeySHA256s[:applePinnedHashSize], serverHash) {
t.Fatal("unexpected first pin")
}
if !bytes.Equal(config.pinnedPublicKeySHA256s[applePinnedHashSize:], otherHash) {
t.Fatal("unexpected second pin")
}
if config.userAnchors != nil {
t.Fatal("unexpected user anchors")
}
if config.anchorOnly {
t.Fatal("unexpected anchor_only")
}
},
},
{
name: "http11 unsupported",
options: option.HTTPClientOptions{Version: 1},
wantErr: "HTTP/1.1 is unsupported in Apple HTTP engine",
},
{
name: "http3 unsupported",
options: option.HTTPClientOptions{Version: 3},
wantErr: "HTTP/3 is unsupported in Apple HTTP engine",
},
{
name: "unknown version",
options: option.HTTPClientOptions{Version: 9},
wantErr: "unknown HTTP version: 9",
},
{
name: "disable version fallback unsupported",
options: option.HTTPClientOptions{
DisableVersionFallback: true,
},
wantErr: "disable_version_fallback is unsupported in Apple HTTP engine",
},
{
name: "http2 options unsupported",
options: option.HTTPClientOptions{
HTTP2Options: option.HTTP2Options{
IdleTimeout: badoption.Duration(time.Second),
},
},
wantErr: "HTTP/2 options are unsupported in Apple HTTP engine",
},
{
name: "quic options unsupported",
options: option.HTTPClientOptions{
HTTP3Options: option.QUICOptions{
InitialPacketSize: 1200,
},
},
wantErr: "QUIC options are unsupported in Apple HTTP engine",
},
{
name: "tls engine unsupported",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{Engine: "go"},
},
},
wantErr: "tls.engine is unsupported in Apple HTTP engine",
},
{
name: "disable sni unsupported",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{DisableSNI: true},
},
},
wantErr: "disable_sni is unsupported in Apple HTTP engine",
},
{
name: "alpn unsupported",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
ALPN: badoption.Listable[string]{"h2"},
},
},
},
wantErr: "tls.alpn is unsupported in Apple HTTP engine",
},
{
name: "cipher suites unsupported",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
CipherSuites: badoption.Listable[string]{"TLS_AES_128_GCM_SHA256"},
},
},
},
wantErr: "cipher_suites is unsupported in Apple HTTP engine",
},
{
name: "curve preferences unsupported",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
CurvePreferences: badoption.Listable[option.CurvePreference]{option.CurvePreference(option.X25519)},
},
},
},
wantErr: "curve_preferences is unsupported in Apple HTTP engine",
},
{
name: "client certificate unsupported",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
ClientCertificate: badoption.Listable[string]{"client-certificate"},
ClientKey: badoption.Listable[string]{"client-key"},
},
},
},
wantErr: "client certificate is unsupported in Apple HTTP engine",
},
{
name: "tls fragment unsupported",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{Fragment: true},
},
},
wantErr: "tls fragment is unsupported in Apple HTTP engine",
},
{
name: "ktls unsupported",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{KernelTx: true},
},
},
wantErr: "ktls is unsupported in Apple HTTP engine",
},
{
name: "ech unsupported",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
ECH: &option.OutboundECHOptions{Enabled: true},
},
},
},
wantErr: "ech is unsupported in Apple HTTP engine",
},
{
name: "utls unsupported",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
UTLS: &option.OutboundUTLSOptions{Enabled: true},
},
},
},
wantErr: "utls is unsupported in Apple HTTP engine",
},
{
name: "reality unsupported",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
Reality: &option.OutboundRealityOptions{Enabled: true},
},
},
},
wantErr: "reality is unsupported in Apple HTTP engine",
},
{
name: "pin and certificate conflict",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
Certificate: badoption.Listable[string]{serverCertificatePEM},
CertificatePublicKeySHA256: badoption.Listable[[]byte]{serverHash},
},
},
},
wantErr: "certificate_public_key_sha256 is conflict with certificate or certificate_path",
},
{
name: "invalid min version",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{MinVersion: "bogus"},
},
},
wantErr: "parse min_version",
},
{
name: "invalid max version",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{MaxVersion: "bogus"},
},
},
wantErr: "parse max_version",
},
{
name: "invalid pin length",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
CertificatePublicKeySHA256: badoption.Listable[[]byte]{{0x01, 0x02}},
},
},
},
wantErr: "invalid certificate_public_key_sha256 length: 2",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
config, err := newAppleSessionConfig(context.Background(), testCase.options)
if testCase.wantErr != "" {
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), testCase.wantErr) {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err != nil {
t.Fatal(err)
}
if testCase.check != nil {
testCase.check(t, config)
}
})
}
}
func TestAppleTransportVerifyPublicKeySHA256(t *testing.T) {
serverCertificate, _ := newAppleHTTPTestCertificate(t, "localhost")
goodHash := certificatePublicKeySHA256(t, serverCertificate.Certificate[0])
badHash := append([]byte(nil), goodHash...)
badHash[0] ^= 0xff
err := verifyApplePinnedPublicKeySHA256(goodHash, serverCertificate.Certificate[0])
if err != nil {
t.Fatalf("expected correct pin to succeed: %v", err)
}
err = verifyApplePinnedPublicKeySHA256(badHash, serverCertificate.Certificate[0])
if err == nil {
t.Fatal("expected incorrect pin to fail")
}
if !strings.Contains(err.Error(), "unrecognized remote public key") {
t.Fatalf("unexpected pin mismatch error: %v", err)
}
err = verifyApplePinnedPublicKeySHA256(goodHash[:applePinnedHashSize-1], serverCertificate.Certificate[0])
if err == nil {
t.Fatal("expected malformed pin list to fail")
}
if !strings.Contains(err.Error(), "invalid pinned public key list") {
t.Fatalf("unexpected malformed pin error: %v", err)
}
}
func TestNewAppleTransportClosesSessionConfigOnBridgeFailure(t *testing.T) {
_, serverCertificatePEM := newAppleHTTPTestCertificate(t, "localhost")
restoreAppleTransportFactories(t)
testAnchors := &appleTestAnchors{ref: unsafe.Pointer(new(int))}
newAppleUserAnchors = func([]byte) (adapter.AppleAnchors, error) {
return testAnchors, nil
}
newAppleProxyBridge = func(context.Context, commonLogger.ContextLogger, string, N.Dialer) (*proxybridge.Bridge, error) {
return nil, errors.New("bridge boom")
}
_, err := newAppleTransport(newAppleHTTPTestContext(), log.NewNOPFactory().NewLogger("httpclient"), &appleHTTPTestDialer{}, appleTransportAnchorOptions(serverCertificatePEM))
if err == nil || !strings.Contains(err.Error(), "bridge boom") {
t.Fatalf("unexpected error: %v", err)
}
if testAnchors.releases != 1 {
t.Fatalf("expected 1 anchor release, got %d", testAnchors.releases)
}
}
func TestNewAppleTransportClosesSessionConfigOnSessionFailure(t *testing.T) {
_, serverCertificatePEM := newAppleHTTPTestCertificate(t, "localhost")
restoreAppleTransportFactories(t)
testAnchors := &appleTestAnchors{ref: unsafe.Pointer(new(int))}
newAppleUserAnchors = func([]byte) (adapter.AppleAnchors, error) {
return testAnchors, nil
}
newAppleTransportSession = func(*appleTransportShared) (unsafe.Pointer, error) {
return nil, errors.New("session boom")
}
_, err := newAppleTransport(newAppleHTTPTestContext(), log.NewNOPFactory().NewLogger("httpclient"), &appleHTTPTestDialer{}, appleTransportAnchorOptions(serverCertificatePEM))
if err == nil || !strings.Contains(err.Error(), "session boom") {
t.Fatalf("unexpected error: %v", err)
}
if testAnchors.releases != 1 {
t.Fatalf("expected 1 anchor release, got %d", testAnchors.releases)
}
}
func TestAppleTransportRoundTripHTTPS(t *testing.T) {
requests := make(chan appleHTTPObservedRequest, 1)
server := startAppleHTTPTestServer(t, func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Error(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
requests <- appleHTTPObservedRequest{
method: r.Method,
body: string(body),
host: r.Host,
values: append([]string(nil), r.Header.Values("X-Test")...),
protoMajor: r.ProtoMajor,
}
w.Header().Set("X-Reply", "apple")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte("response body"))
})
transport := newAppleHTTPTestTransport(t, server, option.HTTPClientOptions{
Version: 2,
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: appleHTTPServerTLSOptions(server),
},
})
request, err := http.NewRequest(http.MethodPost, server.URL("/roundtrip"), bytes.NewReader([]byte("request body")))
if err != nil {
t.Fatal(err)
}
request.Header.Add("X-Test", "one")
request.Header.Add("X-Test", "two")
request.Host = "custom.example"
response, err := transport.RoundTrip(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
responseBody := readResponseBody(t, response)
if response.StatusCode != http.StatusCreated {
t.Fatalf("unexpected status code: %d", response.StatusCode)
}
if response.Status != "201 Created" {
t.Fatalf("unexpected status: %q", response.Status)
}
if response.Header.Get("X-Reply") != "apple" {
t.Fatalf("unexpected response header: %q", response.Header.Get("X-Reply"))
}
if responseBody != "response body" {
t.Fatalf("unexpected response body: %q", responseBody)
}
if response.ContentLength != int64(len(responseBody)) {
t.Fatalf("unexpected content length: %d", response.ContentLength)
}
observed := waitObservedRequest(t, requests)
if observed.method != http.MethodPost {
t.Fatalf("unexpected method: %q", observed.method)
}
if observed.body != "request body" {
t.Fatalf("unexpected request body: %q", observed.body)
}
if observed.host != "custom.example" {
t.Fatalf("unexpected host: %q", observed.host)
}
if observed.protoMajor != 2 {
t.Fatalf("expected HTTP/2 request, got HTTP/%d", observed.protoMajor)
}
var normalizedValues []string
for _, value := range observed.values {
for part := range strings.SplitSeq(value, ",") {
normalizedValues = append(normalizedValues, strings.TrimSpace(part))
}
}
slices.Sort(normalizedValues)
if !slices.Equal(normalizedValues, []string{"one", "two"}) {
t.Fatalf("unexpected header values: %#v", observed.values)
}
}
func TestAppleTransportPinnedPublicKey(t *testing.T) {
server := startAppleHTTPTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("pinned"))
})
goodTransport := newAppleHTTPTestTransport(t, server, option.HTTPClientOptions{
Version: 2,
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
Enabled: true,
ServerName: "localhost",
Insecure: true,
CertificatePublicKeySHA256: badoption.Listable[[]byte]{server.publicKeyHash},
},
},
})
response, err := goodTransport.RoundTrip(newAppleHTTPRequest(t, http.MethodGet, server.URL("/good"), nil))
if err != nil {
t.Fatalf("expected pinned request to succeed: %v", err)
}
response.Body.Close()
badHash := append([]byte(nil), server.publicKeyHash...)
badHash[0] ^= 0xff
badTransport := newAppleHTTPTestTransport(t, server, option.HTTPClientOptions{
Version: 2,
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
Enabled: true,
ServerName: "localhost",
Insecure: true,
CertificatePublicKeySHA256: badoption.Listable[[]byte]{badHash},
},
},
})
response, err = badTransport.RoundTrip(newAppleHTTPRequest(t, http.MethodGet, server.URL("/bad"), nil))
if err == nil {
response.Body.Close()
t.Fatal("expected incorrect pinned public key to fail")
}
}
func TestAppleTransportGuardrails(t *testing.T) {
testCases := []struct {
name string
options option.HTTPClientOptions
buildRequest func(t *testing.T) *http.Request
wantErrSubstr string
}{
{
name: "websocket upgrade rejected",
options: option.HTTPClientOptions{
Version: 2,
},
buildRequest: func(t *testing.T) *http.Request {
t.Helper()
request := newAppleHTTPRequest(t, http.MethodGet, "https://localhost/socket", nil)
request.Header.Set("Connection", "Upgrade")
request.Header.Set("Upgrade", "websocket")
return request
},
wantErrSubstr: "HTTP upgrade requests are unsupported in Apple HTTP engine",
},
{
name: "missing url rejected",
options: option.HTTPClientOptions{
Version: 2,
},
buildRequest: func(t *testing.T) *http.Request {
t.Helper()
return &http.Request{Method: http.MethodGet}
},
wantErrSubstr: "missing request URL",
},
{
name: "unsupported scheme rejected",
options: option.HTTPClientOptions{
Version: 2,
},
buildRequest: func(t *testing.T) *http.Request {
t.Helper()
return newAppleHTTPRequest(t, http.MethodGet, "ftp://localhost/file", nil)
},
wantErrSubstr: "unsupported URL scheme: ftp",
},
{
name: "server name mismatch rejected",
options: option.HTTPClientOptions{
Version: 2,
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
Enabled: true,
ServerName: "example.com",
},
},
},
buildRequest: func(t *testing.T) *http.Request {
t.Helper()
return newAppleHTTPRequest(t, http.MethodGet, "https://localhost/path", nil)
},
wantErrSubstr: "tls.server_name is unsupported in Apple HTTP engine unless it matches request host",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
transport := newAppleHTTPTestTransport(t, nil, testCase.options)
response, err := transport.RoundTrip(testCase.buildRequest(t))
if err == nil {
response.Body.Close()
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), testCase.wantErrSubstr) {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestAppleTransportCancellationRecovery(t *testing.T) {
server := startAppleHTTPTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/block":
select {
case <-r.Context().Done():
return
case <-time.After(appleHTTPTestTimeout):
http.Error(w, "request was not canceled", http.StatusGatewayTimeout)
}
default:
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
})
transport := newAppleHTTPTestTransport(t, server, option.HTTPClientOptions{
Version: 2,
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: appleHTTPServerTLSOptions(server),
},
})
for index := range appleHTTPRecoveryLoops {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
request := newAppleHTTPRequestWithContext(t, ctx, http.MethodGet, server.URL("/block"), nil)
response, err := transport.RoundTrip(request)
cancel()
if err == nil {
response.Body.Close()
t.Fatalf("iteration %d: expected cancellation error", index)
}
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
t.Fatalf("iteration %d: unexpected cancellation error: %v", index, err)
}
response, err = transport.RoundTrip(newAppleHTTPRequest(t, http.MethodGet, server.URL("/ok"), nil))
if err != nil {
t.Fatalf("iteration %d: follow-up request failed: %v", index, err)
}
if body := readResponseBody(t, response); body != "ok" {
response.Body.Close()
t.Fatalf("iteration %d: unexpected follow-up body: %q", index, body)
}
response.Body.Close()
}
}
func TestAppleTransportLifecycle(t *testing.T) {
server := startAppleHTTPTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
transport := newAppleHTTPTestTransport(t, server, option.HTTPClientOptions{
Version: 2,
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: appleHTTPServerTLSOptions(server),
},
})
assertAppleHTTPSucceeds(t, transport, server.URL("/original"))
transport.CloseIdleConnections()
assertAppleHTTPSucceeds(t, transport, server.URL("/reset"))
innerTransport := transport.(*appleTransport)
err := innerTransport.Close()
if err != nil {
t.Fatal(err)
}
response, err := innerTransport.RoundTrip(newAppleHTTPRequest(t, http.MethodGet, server.URL("/closed"), nil))
if err == nil {
response.Body.Close()
t.Fatal("expected closed transport to fail")
}
if !errors.Is(err, net.ErrClosed) {
t.Fatalf("unexpected closed transport error: %v", err)
}
}
func startAppleHTTPTestServer(t *testing.T, handler http.HandlerFunc) *appleHTTPTestServer {
t.Helper()
serverCertificate, serverCertificatePEM := newAppleHTTPTestCertificate(t, "localhost")
server := httptest.NewUnstartedServer(handler)
server.EnableHTTP2 = true
server.TLS = &stdtls.Config{
Certificates: []stdtls.Certificate{serverCertificate},
MinVersion: stdtls.VersionTLS12,
}
server.StartTLS()
t.Cleanup(server.Close)
parsedURL, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
baseURL := *parsedURL
baseURL.Host = net.JoinHostPort("localhost", parsedURL.Port())
return &appleHTTPTestServer{
server: server,
baseURL: baseURL.String(),
dialHost: parsedURL.Hostname(),
certificate: serverCertificate,
certificatePEM: serverCertificatePEM,
publicKeyHash: certificatePublicKeySHA256(t, serverCertificate.Certificate[0]),
}
}
func (s *appleHTTPTestServer) URL(path string) string {
if path == "" {
return s.baseURL
}
if strings.HasPrefix(path, "/") {
return s.baseURL + path
}
return s.baseURL + "/" + path
}
func newAppleHTTPTestTransport(t *testing.T, server *appleHTTPTestServer, options option.HTTPClientOptions) innerTransport {
t.Helper()
ctx := newAppleHTTPTestContext()
dialer := &appleHTTPTestDialer{
hostMap: make(map[string]string),
}
if server != nil {
dialer.hostMap["localhost"] = server.dialHost
}
transport, err := newAppleTransport(ctx, log.NewNOPFactory().NewLogger("httpclient"), dialer, options)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = transport.Close()
})
return transport
}
func newAppleHTTPTestContext() context.Context {
return service.ContextWith[adapter.ConnectionManager](
context.Background(),
route.NewConnectionManager(log.NewNOPFactory().NewLogger("connection")),
)
}
func appleTransportAnchorOptions(certificatePEM string) option.HTTPClientOptions {
return option.HTTPClientOptions{
Version: 2,
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
Enabled: true,
ServerName: "localhost",
MinVersion: "1.2",
Certificate: badoption.Listable[string]{certificatePEM},
},
},
}
}
func restoreAppleTransportFactories(t *testing.T) {
t.Helper()
oldAnchors := newAppleUserAnchors
oldBridge := newAppleProxyBridge
oldSession := newAppleTransportSession
t.Cleanup(func() {
newAppleUserAnchors = oldAnchors
newAppleProxyBridge = oldBridge
newAppleTransportSession = oldSession
})
}
func (d *appleHTTPTestDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
host := destination.AddrString()
if destination.IsDomain() {
host = destination.Fqdn
if mappedHost, loaded := d.hostMap[host]; loaded {
host = mappedHost
}
}
return d.dialer.DialContext(ctx, network, net.JoinHostPort(host, strconv.Itoa(int(destination.Port))))
}
func (d *appleHTTPTestDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
host := destination.AddrString()
if destination.IsDomain() {
host = destination.Fqdn
if mappedHost, loaded := d.hostMap[host]; loaded {
host = mappedHost
}
}
if host == "" {
host = "127.0.0.1"
}
return d.listener.ListenPacket(ctx, N.NetworkUDP, net.JoinHostPort(host, strconv.Itoa(int(destination.Port))))
}
func newAppleHTTPTestCertificate(t *testing.T, serverName string) (stdtls.Certificate, string) {
t.Helper()
privateKeyPEM, certificatePEM, err := boxTLS.GenerateCertificate(nil, nil, time.Now, serverName, time.Now().Add(time.Hour))
if err != nil {
t.Fatal(err)
}
certificate, err := stdtls.X509KeyPair(certificatePEM, privateKeyPEM)
if err != nil {
t.Fatal(err)
}
return certificate, string(certificatePEM)
}
func certificatePublicKeySHA256(t *testing.T, certificateDER []byte) []byte {
t.Helper()
certificate, err := x509.ParseCertificate(certificateDER)
if err != nil {
t.Fatal(err)
}
publicKeyDER, err := x509.MarshalPKIXPublicKey(certificate.PublicKey)
if err != nil {
t.Fatal(err)
}
hashValue := sha256.Sum256(publicKeyDER)
return append([]byte(nil), hashValue[:]...)
}
func appleHTTPServerTLSOptions(server *appleHTTPTestServer) *option.OutboundTLSOptions {
return &option.OutboundTLSOptions{
Enabled: true,
ServerName: "localhost",
Certificate: badoption.Listable[string]{server.certificatePEM},
}
}
func newAppleHTTPRequest(t *testing.T, method string, rawURL string, body []byte) *http.Request {
t.Helper()
return newAppleHTTPRequestWithContext(t, context.Background(), method, rawURL, body)
}
func newAppleHTTPRequestWithContext(t *testing.T, ctx context.Context, method string, rawURL string, body []byte) *http.Request {
t.Helper()
request, err := http.NewRequestWithContext(ctx, method, rawURL, bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
return request
}
func waitObservedRequest(t *testing.T, requests <-chan appleHTTPObservedRequest) appleHTTPObservedRequest {
t.Helper()
select {
case request := <-requests:
return request
case <-time.After(appleHTTPTestTimeout):
t.Fatal("timed out waiting for observed request")
return appleHTTPObservedRequest{}
}
}
func readResponseBody(t *testing.T, response *http.Response) string {
t.Helper()
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
return string(body)
}
func assertAppleHTTPSucceeds(t *testing.T, transport http.RoundTripper, rawURL string) {
t.Helper()
response, err := transport.RoundTrip(newAppleHTTPRequest(t, http.MethodGet, rawURL, nil))
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if body := readResponseBody(t, response); body != "ok" {
t.Fatalf("unexpected response body: %q", body)
}
}