Refactor: HTTP clients, unified HTTP2/QUIC options, Apple engines
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
//go:build darwin && cgo
|
||||
|
||||
package httpclient
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c -fobjc-arc
|
||||
#cgo LDFLAGS: -framework Foundation -framework Security
|
||||
|
||||
#include <stdlib.h>
|
||||
#include "apple_transport_darwin.h"
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/sagernet/sing-box/common/proxybridge"
|
||||
boxTLS "github.com/sagernet/sing-box/common/tls"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
"github.com/sagernet/sing/common/ntp"
|
||||
)
|
||||
|
||||
const applePinnedHashSize = sha256.Size
|
||||
|
||||
func verifyApplePinnedPublicKeySHA256(flatHashes []byte, leafCertificate []byte) error {
|
||||
if len(flatHashes)%applePinnedHashSize != 0 {
|
||||
return E.New("invalid pinned public key list")
|
||||
}
|
||||
knownHashes := make([][]byte, 0, len(flatHashes)/applePinnedHashSize)
|
||||
for offset := 0; offset < len(flatHashes); offset += applePinnedHashSize {
|
||||
knownHashes = append(knownHashes, append([]byte(nil), flatHashes[offset:offset+applePinnedHashSize]...))
|
||||
}
|
||||
return boxTLS.VerifyPublicKeySHA256(knownHashes, [][]byte{leafCertificate})
|
||||
}
|
||||
|
||||
//export box_apple_http_verify_public_key_sha256
|
||||
func box_apple_http_verify_public_key_sha256(knownHashValues *C.uint8_t, knownHashValuesLen C.size_t, leafCert *C.uint8_t, leafCertLen C.size_t) *C.char {
|
||||
flatHashes := C.GoBytes(unsafe.Pointer(knownHashValues), C.int(knownHashValuesLen))
|
||||
leafCertificate := C.GoBytes(unsafe.Pointer(leafCert), C.int(leafCertLen))
|
||||
err := verifyApplePinnedPublicKeySHA256(flatHashes, leafCertificate)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return C.CString(err.Error())
|
||||
}
|
||||
|
||||
type appleSessionConfig struct {
|
||||
serverName string
|
||||
minVersion uint16
|
||||
maxVersion uint16
|
||||
insecure bool
|
||||
anchorPEM string
|
||||
anchorOnly bool
|
||||
pinnedPublicKeySHA256s []byte
|
||||
}
|
||||
|
||||
type appleTransportShared struct {
|
||||
logger logger.ContextLogger
|
||||
bridge *proxybridge.Bridge
|
||||
config appleSessionConfig
|
||||
timeFunc func() time.Time
|
||||
refs atomic.Int32
|
||||
}
|
||||
|
||||
type appleTransport struct {
|
||||
shared *appleTransportShared
|
||||
access sync.Mutex
|
||||
session *C.box_apple_http_session_t
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newAppleTransport(ctx context.Context, logger logger.ContextLogger, rawDialer N.Dialer, options option.HTTPClientOptions) (innerTransport, error) {
|
||||
sessionConfig, err := newAppleSessionConfig(ctx, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bridge, err := proxybridge.New(ctx, logger, "apple http proxy", rawDialer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shared := &appleTransportShared{
|
||||
logger: logger,
|
||||
bridge: bridge,
|
||||
config: sessionConfig,
|
||||
timeFunc: ntp.TimeFuncFromContext(ctx),
|
||||
}
|
||||
shared.refs.Store(1)
|
||||
session, err := shared.newSession()
|
||||
if err != nil {
|
||||
bridge.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &appleTransport{
|
||||
shared: shared,
|
||||
session: session,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newAppleSessionConfig(ctx context.Context, options option.HTTPClientOptions) (appleSessionConfig, error) {
|
||||
version := options.Version
|
||||
if version == 0 {
|
||||
version = 2
|
||||
}
|
||||
switch version {
|
||||
case 2:
|
||||
case 1:
|
||||
return appleSessionConfig{}, E.New("HTTP/1.1 is unsupported in Apple HTTP engine")
|
||||
case 3:
|
||||
return appleSessionConfig{}, E.New("HTTP/3 is unsupported in Apple HTTP engine")
|
||||
default:
|
||||
return appleSessionConfig{}, E.New("unknown HTTP version: ", version)
|
||||
}
|
||||
if options.DisableVersionFallback {
|
||||
return appleSessionConfig{}, E.New("disable_version_fallback is unsupported in Apple HTTP engine")
|
||||
}
|
||||
if options.HTTP2Options != (option.HTTP2Options{}) {
|
||||
return appleSessionConfig{}, E.New("HTTP/2 options are unsupported in Apple HTTP engine")
|
||||
}
|
||||
if options.HTTP3Options != (option.QUICOptions{}) {
|
||||
return appleSessionConfig{}, E.New("QUIC options are unsupported in Apple HTTP engine")
|
||||
}
|
||||
|
||||
tlsOptions := common.PtrValueOrDefault(options.TLS)
|
||||
if tlsOptions.Engine != "" {
|
||||
return appleSessionConfig{}, E.New("tls.engine is unsupported in Apple HTTP engine")
|
||||
}
|
||||
if len(tlsOptions.ALPN) > 0 {
|
||||
return appleSessionConfig{}, E.New("tls.alpn is unsupported in Apple HTTP engine")
|
||||
}
|
||||
validated, err := boxTLS.ValidateAppleTLSOptions(ctx, tlsOptions, "Apple HTTP engine")
|
||||
if err != nil {
|
||||
return appleSessionConfig{}, err
|
||||
}
|
||||
|
||||
config := appleSessionConfig{
|
||||
serverName: tlsOptions.ServerName,
|
||||
minVersion: validated.MinVersion,
|
||||
maxVersion: validated.MaxVersion,
|
||||
insecure: tlsOptions.Insecure || len(tlsOptions.CertificatePublicKeySHA256) > 0,
|
||||
anchorPEM: validated.AnchorPEM,
|
||||
anchorOnly: validated.AnchorOnly,
|
||||
}
|
||||
if len(tlsOptions.CertificatePublicKeySHA256) > 0 {
|
||||
config.pinnedPublicKeySHA256s = make([]byte, 0, len(tlsOptions.CertificatePublicKeySHA256)*applePinnedHashSize)
|
||||
for _, hashValue := range tlsOptions.CertificatePublicKeySHA256 {
|
||||
if len(hashValue) != applePinnedHashSize {
|
||||
return appleSessionConfig{}, E.New("invalid certificate_public_key_sha256 length: ", len(hashValue))
|
||||
}
|
||||
config.pinnedPublicKeySHA256s = append(config.pinnedPublicKeySHA256s, hashValue...)
|
||||
}
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *appleTransportShared) retain() {
|
||||
s.refs.Add(1)
|
||||
}
|
||||
|
||||
func (s *appleTransportShared) release() error {
|
||||
if s.refs.Add(-1) == 0 {
|
||||
return s.bridge.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *appleTransportShared) newSession() (*C.box_apple_http_session_t, error) {
|
||||
cProxyHost := C.CString("127.0.0.1")
|
||||
defer C.free(unsafe.Pointer(cProxyHost))
|
||||
cProxyUsername := C.CString(s.bridge.Username())
|
||||
defer C.free(unsafe.Pointer(cProxyUsername))
|
||||
cProxyPassword := C.CString(s.bridge.Password())
|
||||
defer C.free(unsafe.Pointer(cProxyPassword))
|
||||
var cAnchorPEM *C.char
|
||||
if s.config.anchorPEM != "" {
|
||||
cAnchorPEM = C.CString(s.config.anchorPEM)
|
||||
defer C.free(unsafe.Pointer(cAnchorPEM))
|
||||
}
|
||||
var pinnedPointer *C.uint8_t
|
||||
if len(s.config.pinnedPublicKeySHA256s) > 0 {
|
||||
pinnedPointer = (*C.uint8_t)(C.CBytes(s.config.pinnedPublicKeySHA256s))
|
||||
defer C.free(unsafe.Pointer(pinnedPointer))
|
||||
}
|
||||
cConfig := C.box_apple_http_session_config_t{
|
||||
proxy_host: cProxyHost,
|
||||
proxy_port: C.int(s.bridge.Port()),
|
||||
proxy_username: cProxyUsername,
|
||||
proxy_password: cProxyPassword,
|
||||
min_tls_version: C.uint16_t(s.config.minVersion),
|
||||
max_tls_version: C.uint16_t(s.config.maxVersion),
|
||||
insecure: C.bool(s.config.insecure),
|
||||
anchor_pem: cAnchorPEM,
|
||||
anchor_pem_len: C.size_t(len(s.config.anchorPEM)),
|
||||
anchor_only: C.bool(s.config.anchorOnly),
|
||||
pinned_public_key_sha256: pinnedPointer,
|
||||
pinned_public_key_sha256_len: C.size_t(len(s.config.pinnedPublicKeySHA256s)),
|
||||
}
|
||||
var cErr *C.char
|
||||
session := C.box_apple_http_session_create(&cConfig, &cErr)
|
||||
if session != nil {
|
||||
return session, nil
|
||||
}
|
||||
return nil, appleCStringError(cErr, "create Apple HTTP session")
|
||||
}
|
||||
|
||||
func (t *appleTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
if requestRequiresHTTP1(request) {
|
||||
return nil, E.New("HTTP upgrade requests are unsupported in Apple HTTP engine")
|
||||
}
|
||||
if request.URL == nil {
|
||||
return nil, E.New("missing request URL")
|
||||
}
|
||||
switch request.URL.Scheme {
|
||||
case "http", "https":
|
||||
default:
|
||||
return nil, E.New("unsupported URL scheme: ", request.URL.Scheme)
|
||||
}
|
||||
if request.URL.Scheme == "https" && t.shared.config.serverName != "" && !strings.EqualFold(t.shared.config.serverName, request.URL.Hostname()) {
|
||||
return nil, E.New("tls.server_name is unsupported in Apple HTTP engine unless it matches request host")
|
||||
}
|
||||
var body []byte
|
||||
if request.Body != nil && request.Body != http.NoBody {
|
||||
defer request.Body.Close()
|
||||
var err error
|
||||
body, err = io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
headerKeys, headerValues := flattenRequestHeaders(request)
|
||||
cMethod := C.CString(request.Method)
|
||||
defer C.free(unsafe.Pointer(cMethod))
|
||||
cURL := C.CString(request.URL.String())
|
||||
defer C.free(unsafe.Pointer(cURL))
|
||||
cHeaderKeys := make([]*C.char, len(headerKeys))
|
||||
cHeaderValues := make([]*C.char, len(headerValues))
|
||||
defer func() {
|
||||
for _, ptr := range cHeaderKeys {
|
||||
C.free(unsafe.Pointer(ptr))
|
||||
}
|
||||
for _, ptr := range cHeaderValues {
|
||||
C.free(unsafe.Pointer(ptr))
|
||||
}
|
||||
}()
|
||||
for index, value := range headerKeys {
|
||||
cHeaderKeys[index] = C.CString(value)
|
||||
}
|
||||
for index, value := range headerValues {
|
||||
cHeaderValues[index] = C.CString(value)
|
||||
}
|
||||
var headerKeysPointer **C.char
|
||||
var headerValuesPointer **C.char
|
||||
if len(cHeaderKeys) > 0 {
|
||||
pointerArraySize := C.size_t(len(cHeaderKeys)) * C.size_t(unsafe.Sizeof((*C.char)(nil)))
|
||||
headerKeysPointer = (**C.char)(C.malloc(pointerArraySize))
|
||||
defer C.free(unsafe.Pointer(headerKeysPointer))
|
||||
headerValuesPointer = (**C.char)(C.malloc(pointerArraySize))
|
||||
defer C.free(unsafe.Pointer(headerValuesPointer))
|
||||
copy(unsafe.Slice(headerKeysPointer, len(cHeaderKeys)), cHeaderKeys)
|
||||
copy(unsafe.Slice(headerValuesPointer, len(cHeaderValues)), cHeaderValues)
|
||||
}
|
||||
var bodyPointer *C.uint8_t
|
||||
if len(body) > 0 {
|
||||
bodyPointer = (*C.uint8_t)(C.CBytes(body))
|
||||
defer C.free(unsafe.Pointer(bodyPointer))
|
||||
}
|
||||
var (
|
||||
hasVerifyTime bool
|
||||
verifyTimeUnixMilli int64
|
||||
)
|
||||
if t.shared.timeFunc != nil {
|
||||
hasVerifyTime = true
|
||||
verifyTimeUnixMilli = t.shared.timeFunc().UnixMilli()
|
||||
}
|
||||
cRequest := C.box_apple_http_request_t{
|
||||
method: cMethod,
|
||||
url: cURL,
|
||||
header_keys: (**C.char)(headerKeysPointer),
|
||||
header_values: (**C.char)(headerValuesPointer),
|
||||
header_count: C.size_t(len(cHeaderKeys)),
|
||||
body: bodyPointer,
|
||||
body_len: C.size_t(len(body)),
|
||||
has_verify_time: C.bool(hasVerifyTime),
|
||||
verify_time_unix_millis: C.int64_t(verifyTimeUnixMilli),
|
||||
}
|
||||
var cErr *C.char
|
||||
var task *C.box_apple_http_task_t
|
||||
t.access.Lock()
|
||||
if t.session == nil {
|
||||
t.access.Unlock()
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
// Keep the session attached until NSURLSession has created the task.
|
||||
task = C.box_apple_http_session_send_async(t.session, &cRequest, &cErr)
|
||||
t.access.Unlock()
|
||||
if task == nil {
|
||||
return nil, appleCStringError(cErr, "create Apple HTTP request")
|
||||
}
|
||||
cancelDone := make(chan struct{})
|
||||
cancelExit := make(chan struct{})
|
||||
go func() {
|
||||
defer close(cancelExit)
|
||||
select {
|
||||
case <-request.Context().Done():
|
||||
C.box_apple_http_task_cancel(task)
|
||||
case <-cancelDone:
|
||||
}
|
||||
}()
|
||||
cResponse := C.box_apple_http_task_wait(task, &cErr)
|
||||
close(cancelDone)
|
||||
<-cancelExit
|
||||
C.box_apple_http_task_close(task)
|
||||
if cResponse == nil {
|
||||
err := appleCStringError(cErr, "Apple HTTP request failed")
|
||||
if request.Context().Err() != nil {
|
||||
return nil, request.Context().Err()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer C.box_apple_http_response_free(cResponse)
|
||||
return parseAppleHTTPResponse(request, cResponse), nil
|
||||
}
|
||||
|
||||
func (t *appleTransport) CloseIdleConnections() {
|
||||
t.access.Lock()
|
||||
if t.closed {
|
||||
t.access.Unlock()
|
||||
return
|
||||
}
|
||||
t.access.Unlock()
|
||||
newSession, err := t.shared.newSession()
|
||||
if err != nil {
|
||||
t.shared.logger.Error(E.Cause(err, "reset Apple HTTP session"))
|
||||
return
|
||||
}
|
||||
t.access.Lock()
|
||||
if t.closed {
|
||||
t.access.Unlock()
|
||||
C.box_apple_http_session_close(newSession)
|
||||
return
|
||||
}
|
||||
oldSession := t.session
|
||||
t.session = newSession
|
||||
t.access.Unlock()
|
||||
C.box_apple_http_session_retire(oldSession)
|
||||
}
|
||||
|
||||
func (t *appleTransport) Close() error {
|
||||
t.access.Lock()
|
||||
if t.closed {
|
||||
t.access.Unlock()
|
||||
return nil
|
||||
}
|
||||
t.closed = true
|
||||
session := t.session
|
||||
t.session = nil
|
||||
t.access.Unlock()
|
||||
C.box_apple_http_session_close(session)
|
||||
return t.shared.release()
|
||||
}
|
||||
|
||||
func flattenRequestHeaders(request *http.Request) ([]string, []string) {
|
||||
var (
|
||||
keys []string
|
||||
values []string
|
||||
)
|
||||
for key, headerValues := range request.Header {
|
||||
for _, value := range headerValues {
|
||||
keys = append(keys, key)
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
if request.Host != "" {
|
||||
keys = append(keys, "Host")
|
||||
values = append(values, request.Host)
|
||||
}
|
||||
return keys, values
|
||||
}
|
||||
|
||||
func parseAppleHTTPResponse(request *http.Request, response *C.box_apple_http_response_t) *http.Response {
|
||||
headers := make(http.Header)
|
||||
headerKeys := unsafe.Slice(response.header_keys, int(response.header_count))
|
||||
headerValues := unsafe.Slice(response.header_values, int(response.header_count))
|
||||
for index := range headerKeys {
|
||||
headers.Add(C.GoString(headerKeys[index]), C.GoString(headerValues[index]))
|
||||
}
|
||||
body := bytes.NewReader(C.GoBytes(unsafe.Pointer(response.body), C.int(response.body_len)))
|
||||
// NSURLSession's completion-handler API does not expose the negotiated protocol;
|
||||
// callers that read Response.Proto will see HTTP/1.1 even when the wire was HTTP/2.
|
||||
return &http.Response{
|
||||
StatusCode: int(response.status_code),
|
||||
Status: fmt.Sprintf("%d %s", int(response.status_code), http.StatusText(int(response.status_code))),
|
||||
Proto: "HTTP/1.1",
|
||||
ProtoMajor: 1,
|
||||
ProtoMinor: 1,
|
||||
Header: headers,
|
||||
Body: io.NopCloser(body),
|
||||
ContentLength: int64(body.Len()),
|
||||
Request: request,
|
||||
}
|
||||
}
|
||||
|
||||
func appleCStringError(cErr *C.char, message string) error {
|
||||
if cErr == nil {
|
||||
return E.New(message)
|
||||
}
|
||||
defer C.free(unsafe.Pointer(cErr))
|
||||
return E.New(message, ": ", C.GoString(cErr))
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
typedef struct box_apple_http_session box_apple_http_session_t;
|
||||
typedef struct box_apple_http_task box_apple_http_task_t;
|
||||
|
||||
typedef struct box_apple_http_session_config {
|
||||
const char *proxy_host;
|
||||
int proxy_port;
|
||||
const char *proxy_username;
|
||||
const char *proxy_password;
|
||||
uint16_t min_tls_version;
|
||||
uint16_t max_tls_version;
|
||||
bool insecure;
|
||||
const char *anchor_pem;
|
||||
size_t anchor_pem_len;
|
||||
bool anchor_only;
|
||||
const uint8_t *pinned_public_key_sha256;
|
||||
size_t pinned_public_key_sha256_len;
|
||||
} box_apple_http_session_config_t;
|
||||
|
||||
typedef struct box_apple_http_request {
|
||||
const char *method;
|
||||
const char *url;
|
||||
const char **header_keys;
|
||||
const char **header_values;
|
||||
size_t header_count;
|
||||
const uint8_t *body;
|
||||
size_t body_len;
|
||||
bool has_verify_time;
|
||||
int64_t verify_time_unix_millis;
|
||||
} box_apple_http_request_t;
|
||||
|
||||
typedef struct box_apple_http_response {
|
||||
int status_code;
|
||||
char **header_keys;
|
||||
char **header_values;
|
||||
size_t header_count;
|
||||
uint8_t *body;
|
||||
size_t body_len;
|
||||
char *error;
|
||||
} box_apple_http_response_t;
|
||||
|
||||
box_apple_http_session_t *box_apple_http_session_create(
|
||||
const box_apple_http_session_config_t *config,
|
||||
char **error_out
|
||||
);
|
||||
void box_apple_http_session_retire(box_apple_http_session_t *session);
|
||||
void box_apple_http_session_close(box_apple_http_session_t *session);
|
||||
|
||||
box_apple_http_task_t *box_apple_http_session_send_async(
|
||||
box_apple_http_session_t *session,
|
||||
const box_apple_http_request_t *request,
|
||||
char **error_out
|
||||
);
|
||||
box_apple_http_response_t *box_apple_http_task_wait(
|
||||
box_apple_http_task_t *task,
|
||||
char **error_out
|
||||
);
|
||||
void box_apple_http_task_cancel(box_apple_http_task_t *task);
|
||||
void box_apple_http_task_close(box_apple_http_task_t *task);
|
||||
|
||||
void box_apple_http_response_free(box_apple_http_response_t *response);
|
||||
|
||||
char *box_apple_http_verify_public_key_sha256(
|
||||
uint8_t *known_hash_values,
|
||||
size_t known_hash_values_len,
|
||||
uint8_t *leaf_cert,
|
||||
size_t leaf_cert_len
|
||||
);
|
||||
@@ -0,0 +1,398 @@
|
||||
#import "apple_transport_darwin.h"
|
||||
|
||||
#import <CoreFoundation/CFStream.h>
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <Security/Security.h>
|
||||
#import <dispatch/dispatch.h>
|
||||
#import <stdlib.h>
|
||||
#import <string.h>
|
||||
|
||||
typedef struct box_apple_http_session {
|
||||
void *handle;
|
||||
} box_apple_http_session_t;
|
||||
|
||||
typedef struct box_apple_http_task {
|
||||
void *task;
|
||||
void *done_semaphore;
|
||||
box_apple_http_response_t *response;
|
||||
char *error;
|
||||
} box_apple_http_task_t;
|
||||
|
||||
static NSString *const box_apple_http_verify_time_key = @"sing-box.verify-time";
|
||||
|
||||
static void box_set_error_string(char **error_out, NSString *message) {
|
||||
if (error_out == NULL || *error_out != NULL) {
|
||||
return;
|
||||
}
|
||||
const char *utf8 = [message UTF8String];
|
||||
*error_out = strdup(utf8 != NULL ? utf8 : "unknown error");
|
||||
}
|
||||
|
||||
static void box_set_error_from_nserror(char **error_out, NSError *error) {
|
||||
if (error == nil) {
|
||||
box_set_error_string(error_out, @"unknown error");
|
||||
return;
|
||||
}
|
||||
box_set_error_string(error_out, error.localizedDescription ?: error.description);
|
||||
}
|
||||
|
||||
static NSArray *box_parse_certificates_from_pem(const char *pem, size_t pem_len) {
|
||||
if (pem == NULL || pem_len == 0) {
|
||||
return @[];
|
||||
}
|
||||
NSString *content = [[NSString alloc] initWithBytes:pem length:pem_len encoding:NSUTF8StringEncoding];
|
||||
if (content == nil) {
|
||||
return @[];
|
||||
}
|
||||
NSString *beginMarker = @"-----BEGIN CERTIFICATE-----";
|
||||
NSString *endMarker = @"-----END CERTIFICATE-----";
|
||||
NSMutableArray *certificates = [NSMutableArray array];
|
||||
NSUInteger searchFrom = 0;
|
||||
while (searchFrom < content.length) {
|
||||
NSRange beginRange = [content rangeOfString:beginMarker options:0 range:NSMakeRange(searchFrom, content.length - searchFrom)];
|
||||
if (beginRange.location == NSNotFound) {
|
||||
break;
|
||||
}
|
||||
NSUInteger bodyStart = beginRange.location + beginRange.length;
|
||||
NSRange endRange = [content rangeOfString:endMarker options:0 range:NSMakeRange(bodyStart, content.length - bodyStart)];
|
||||
if (endRange.location == NSNotFound) {
|
||||
break;
|
||||
}
|
||||
NSString *base64Section = [content substringWithRange:NSMakeRange(bodyStart, endRange.location - bodyStart)];
|
||||
NSArray<NSString *> *components = [base64Section componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
NSString *base64Content = [components componentsJoinedByString:@""];
|
||||
NSData *der = [[NSData alloc] initWithBase64EncodedString:base64Content options:0];
|
||||
if (der != nil) {
|
||||
SecCertificateRef certificate = SecCertificateCreateWithData(NULL, (__bridge CFDataRef)der);
|
||||
if (certificate != NULL) {
|
||||
[certificates addObject:(__bridge id)certificate];
|
||||
CFRelease(certificate);
|
||||
}
|
||||
}
|
||||
searchFrom = endRange.location + endRange.length;
|
||||
}
|
||||
return certificates;
|
||||
}
|
||||
|
||||
static bool box_evaluate_trust(SecTrustRef trustRef, NSArray *anchors, bool anchor_only, NSDate *verifyDate) {
|
||||
if (trustRef == NULL) {
|
||||
return false;
|
||||
}
|
||||
if (verifyDate != nil && SecTrustSetVerifyDate(trustRef, (__bridge CFDateRef)verifyDate) != errSecSuccess) {
|
||||
return false;
|
||||
}
|
||||
if (anchors.count > 0 || anchor_only) {
|
||||
CFMutableArrayRef anchorArray = CFArrayCreateMutable(NULL, 0, &kCFTypeArrayCallBacks);
|
||||
for (id certificate in anchors) {
|
||||
CFArrayAppendValue(anchorArray, (__bridge const void *)certificate);
|
||||
}
|
||||
SecTrustSetAnchorCertificates(trustRef, anchorArray);
|
||||
SecTrustSetAnchorCertificatesOnly(trustRef, anchor_only);
|
||||
CFRelease(anchorArray);
|
||||
}
|
||||
CFErrorRef error = NULL;
|
||||
bool result = SecTrustEvaluateWithError(trustRef, &error);
|
||||
if (error != NULL) {
|
||||
CFRelease(error);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static NSDate *box_apple_http_verify_date_for_request(NSURLRequest *request) {
|
||||
if (request == nil) {
|
||||
return nil;
|
||||
}
|
||||
id value = [NSURLProtocol propertyForKey:box_apple_http_verify_time_key inRequest:request];
|
||||
if (![value isKindOfClass:[NSNumber class]]) {
|
||||
return nil;
|
||||
}
|
||||
return [NSDate dateWithTimeIntervalSince1970:[(NSNumber *)value longLongValue] / 1000.0];
|
||||
}
|
||||
|
||||
static box_apple_http_response_t *box_create_response(NSHTTPURLResponse *httpResponse, NSData *data) {
|
||||
box_apple_http_response_t *response = calloc(1, sizeof(box_apple_http_response_t));
|
||||
response->status_code = (int)httpResponse.statusCode;
|
||||
NSDictionary *headers = httpResponse.allHeaderFields;
|
||||
response->header_count = headers.count;
|
||||
if (response->header_count > 0) {
|
||||
response->header_keys = calloc(response->header_count, sizeof(char *));
|
||||
response->header_values = calloc(response->header_count, sizeof(char *));
|
||||
NSUInteger index = 0;
|
||||
for (id key in headers) {
|
||||
NSString *keyString = [[key description] copy];
|
||||
NSString *valueString = [[headers[key] description] copy];
|
||||
response->header_keys[index] = strdup(keyString.UTF8String ?: "");
|
||||
response->header_values[index] = strdup(valueString.UTF8String ?: "");
|
||||
index++;
|
||||
}
|
||||
}
|
||||
if (data.length > 0) {
|
||||
response->body_len = data.length;
|
||||
response->body = malloc(data.length);
|
||||
memcpy(response->body, data.bytes, data.length);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@interface BoxAppleHTTPSessionDelegate : NSObject <NSURLSessionTaskDelegate, NSURLSessionDataDelegate>
|
||||
@property(nonatomic, assign) BOOL insecure;
|
||||
@property(nonatomic, assign) BOOL anchorOnly;
|
||||
@property(nonatomic, strong) NSArray *anchors;
|
||||
@property(nonatomic, strong) NSData *pinnedPublicKeyHashes;
|
||||
@end
|
||||
|
||||
@implementation BoxAppleHTTPSessionDelegate
|
||||
|
||||
- (void)URLSession:(NSURLSession *)session
|
||||
task:(NSURLSessionTask *)task
|
||||
willPerformHTTPRedirection:(NSHTTPURLResponse *)response
|
||||
newRequest:(NSURLRequest *)request
|
||||
completionHandler:(void (^)(NSURLRequest * _Nullable))completionHandler {
|
||||
completionHandler(nil);
|
||||
}
|
||||
|
||||
- (void)URLSession:(NSURLSession *)session
|
||||
task:(NSURLSessionTask *)task
|
||||
didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge
|
||||
completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential * _Nullable credential))completionHandler {
|
||||
if (![challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
|
||||
completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil);
|
||||
return;
|
||||
}
|
||||
SecTrustRef trustRef = challenge.protectionSpace.serverTrust;
|
||||
if (trustRef == NULL) {
|
||||
completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil);
|
||||
return;
|
||||
}
|
||||
NSDate *verifyDate = box_apple_http_verify_date_for_request(task.currentRequest ?: task.originalRequest);
|
||||
BOOL needsCustomHandling = self.insecure || self.anchorOnly || self.anchors.count > 0 || self.pinnedPublicKeyHashes.length > 0 || verifyDate != nil;
|
||||
if (!needsCustomHandling) {
|
||||
completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil);
|
||||
return;
|
||||
}
|
||||
BOOL ok = YES;
|
||||
if (!self.insecure) {
|
||||
ok = box_evaluate_trust(trustRef, self.anchors, self.anchorOnly, verifyDate);
|
||||
}
|
||||
if (ok && self.pinnedPublicKeyHashes.length > 0) {
|
||||
CFArrayRef certificateChain = SecTrustCopyCertificateChain(trustRef);
|
||||
SecCertificateRef leafCertificate = NULL;
|
||||
if (certificateChain != NULL && CFArrayGetCount(certificateChain) > 0) {
|
||||
leafCertificate = (SecCertificateRef)CFArrayGetValueAtIndex(certificateChain, 0);
|
||||
}
|
||||
if (leafCertificate == NULL) {
|
||||
ok = NO;
|
||||
} else {
|
||||
NSData *leafData = CFBridgingRelease(SecCertificateCopyData(leafCertificate));
|
||||
char *pinError = box_apple_http_verify_public_key_sha256(
|
||||
(uint8_t *)self.pinnedPublicKeyHashes.bytes,
|
||||
self.pinnedPublicKeyHashes.length,
|
||||
(uint8_t *)leafData.bytes,
|
||||
leafData.length
|
||||
);
|
||||
if (pinError != NULL) {
|
||||
free(pinError);
|
||||
ok = NO;
|
||||
}
|
||||
}
|
||||
if (certificateChain != NULL) {
|
||||
CFRelease(certificateChain);
|
||||
}
|
||||
}
|
||||
if (!ok) {
|
||||
completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil);
|
||||
return;
|
||||
}
|
||||
completionHandler(NSURLSessionAuthChallengeUseCredential, [NSURLCredential credentialForTrust:trustRef]);
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@interface BoxAppleHTTPSessionHandle : NSObject
|
||||
@property(nonatomic, strong) NSURLSession *session;
|
||||
@property(nonatomic, strong) BoxAppleHTTPSessionDelegate *delegate;
|
||||
@end
|
||||
|
||||
@implementation BoxAppleHTTPSessionHandle
|
||||
@end
|
||||
|
||||
box_apple_http_session_t *box_apple_http_session_create(
|
||||
const box_apple_http_session_config_t *config,
|
||||
char **error_out
|
||||
) {
|
||||
@autoreleasepool {
|
||||
NSURLSessionConfiguration *sessionConfig = [NSURLSessionConfiguration ephemeralSessionConfiguration];
|
||||
sessionConfig.URLCache = nil;
|
||||
sessionConfig.HTTPCookieStorage = nil;
|
||||
sessionConfig.URLCredentialStorage = nil;
|
||||
sessionConfig.HTTPShouldSetCookies = NO;
|
||||
if (config != NULL && config->proxy_host != NULL && config->proxy_port > 0) {
|
||||
NSMutableDictionary *proxyDictionary = [NSMutableDictionary dictionary];
|
||||
proxyDictionary[(__bridge NSString *)kCFStreamPropertySOCKSProxyHost] = [NSString stringWithUTF8String:config->proxy_host];
|
||||
proxyDictionary[(__bridge NSString *)kCFStreamPropertySOCKSProxyPort] = @(config->proxy_port);
|
||||
proxyDictionary[(__bridge NSString *)kCFStreamPropertySOCKSVersion] = (__bridge NSString *)kCFStreamSocketSOCKSVersion5;
|
||||
if (config->proxy_username != NULL) {
|
||||
proxyDictionary[(__bridge NSString *)kCFStreamPropertySOCKSUser] = [NSString stringWithUTF8String:config->proxy_username];
|
||||
}
|
||||
if (config->proxy_password != NULL) {
|
||||
proxyDictionary[(__bridge NSString *)kCFStreamPropertySOCKSPassword] = [NSString stringWithUTF8String:config->proxy_password];
|
||||
}
|
||||
sessionConfig.connectionProxyDictionary = proxyDictionary;
|
||||
}
|
||||
if (config != NULL && config->min_tls_version != 0) {
|
||||
sessionConfig.TLSMinimumSupportedProtocolVersion = (tls_protocol_version_t)config->min_tls_version;
|
||||
}
|
||||
if (config != NULL && config->max_tls_version != 0) {
|
||||
sessionConfig.TLSMaximumSupportedProtocolVersion = (tls_protocol_version_t)config->max_tls_version;
|
||||
}
|
||||
BoxAppleHTTPSessionDelegate *delegate = [[BoxAppleHTTPSessionDelegate alloc] init];
|
||||
if (config != NULL) {
|
||||
delegate.insecure = config->insecure;
|
||||
delegate.anchorOnly = config->anchor_only;
|
||||
delegate.anchors = box_parse_certificates_from_pem(config->anchor_pem, config->anchor_pem_len);
|
||||
if (config->pinned_public_key_sha256 != NULL && config->pinned_public_key_sha256_len > 0) {
|
||||
delegate.pinnedPublicKeyHashes = [NSData dataWithBytes:config->pinned_public_key_sha256 length:config->pinned_public_key_sha256_len];
|
||||
}
|
||||
}
|
||||
NSURLSession *session = [NSURLSession sessionWithConfiguration:sessionConfig delegate:delegate delegateQueue:nil];
|
||||
if (session == nil) {
|
||||
box_set_error_string(error_out, @"create URLSession");
|
||||
return NULL;
|
||||
}
|
||||
BoxAppleHTTPSessionHandle *handle = [[BoxAppleHTTPSessionHandle alloc] init];
|
||||
handle.session = session;
|
||||
handle.delegate = delegate;
|
||||
box_apple_http_session_t *sessionHandle = calloc(1, sizeof(box_apple_http_session_t));
|
||||
sessionHandle->handle = (__bridge_retained void *)handle;
|
||||
return sessionHandle;
|
||||
}
|
||||
}
|
||||
|
||||
void box_apple_http_session_retire(box_apple_http_session_t *session) {
|
||||
if (session == NULL || session->handle == NULL) {
|
||||
return;
|
||||
}
|
||||
BoxAppleHTTPSessionHandle *handle = (__bridge_transfer BoxAppleHTTPSessionHandle *)session->handle;
|
||||
[handle.session finishTasksAndInvalidate];
|
||||
free(session);
|
||||
}
|
||||
|
||||
void box_apple_http_session_close(box_apple_http_session_t *session) {
|
||||
if (session == NULL || session->handle == NULL) {
|
||||
return;
|
||||
}
|
||||
BoxAppleHTTPSessionHandle *handle = (__bridge_transfer BoxAppleHTTPSessionHandle *)session->handle;
|
||||
[handle.session invalidateAndCancel];
|
||||
free(session);
|
||||
}
|
||||
|
||||
box_apple_http_task_t *box_apple_http_session_send_async(
|
||||
box_apple_http_session_t *session,
|
||||
const box_apple_http_request_t *request,
|
||||
char **error_out
|
||||
) {
|
||||
@autoreleasepool {
|
||||
if (session == NULL || session->handle == NULL || request == NULL || request->method == NULL || request->url == NULL) {
|
||||
box_set_error_string(error_out, @"invalid apple HTTP request");
|
||||
return NULL;
|
||||
}
|
||||
BoxAppleHTTPSessionHandle *handle = (__bridge BoxAppleHTTPSessionHandle *)session->handle;
|
||||
NSURL *requestURL = [NSURL URLWithString:[NSString stringWithUTF8String:request->url]];
|
||||
if (requestURL == nil) {
|
||||
box_set_error_string(error_out, @"invalid request URL");
|
||||
return NULL;
|
||||
}
|
||||
NSMutableURLRequest *urlRequest = [NSMutableURLRequest requestWithURL:requestURL];
|
||||
urlRequest.HTTPMethod = [NSString stringWithUTF8String:request->method];
|
||||
for (size_t index = 0; index < request->header_count; index++) {
|
||||
const char *key = request->header_keys[index];
|
||||
const char *value = request->header_values[index];
|
||||
if (key == NULL || value == NULL) {
|
||||
continue;
|
||||
}
|
||||
[urlRequest addValue:[NSString stringWithUTF8String:value] forHTTPHeaderField:[NSString stringWithUTF8String:key]];
|
||||
}
|
||||
if (request->body != NULL && request->body_len > 0) {
|
||||
urlRequest.HTTPBody = [NSData dataWithBytes:request->body length:request->body_len];
|
||||
}
|
||||
if (request->has_verify_time) {
|
||||
[NSURLProtocol setProperty:@(request->verify_time_unix_millis) forKey:box_apple_http_verify_time_key inRequest:urlRequest];
|
||||
}
|
||||
box_apple_http_task_t *task = calloc(1, sizeof(box_apple_http_task_t));
|
||||
dispatch_semaphore_t doneSemaphore = dispatch_semaphore_create(0);
|
||||
task->done_semaphore = (__bridge_retained void *)doneSemaphore;
|
||||
NSURLSessionDataTask *dataTask = [handle.session dataTaskWithRequest:urlRequest completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
|
||||
if (error != nil) {
|
||||
box_set_error_from_nserror(&task->error, error);
|
||||
} else if (![response isKindOfClass:[NSHTTPURLResponse class]]) {
|
||||
box_set_error_string(&task->error, @"unexpected HTTP response type");
|
||||
} else {
|
||||
task->response = box_create_response((NSHTTPURLResponse *)response, data ?: [NSData data]);
|
||||
}
|
||||
dispatch_semaphore_signal((__bridge dispatch_semaphore_t)task->done_semaphore);
|
||||
}];
|
||||
if (dataTask == nil) {
|
||||
box_set_error_string(error_out, @"create data task");
|
||||
box_apple_http_task_close(task);
|
||||
return NULL;
|
||||
}
|
||||
task->task = (__bridge_retained void *)dataTask;
|
||||
[dataTask resume];
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
||||
box_apple_http_response_t *box_apple_http_task_wait(
|
||||
box_apple_http_task_t *task,
|
||||
char **error_out
|
||||
) {
|
||||
if (task == NULL || task->done_semaphore == NULL) {
|
||||
box_set_error_string(error_out, @"invalid apple HTTP task");
|
||||
return NULL;
|
||||
}
|
||||
dispatch_semaphore_wait((__bridge dispatch_semaphore_t)task->done_semaphore, DISPATCH_TIME_FOREVER);
|
||||
if (task->error != NULL) {
|
||||
box_set_error_string(error_out, [NSString stringWithUTF8String:task->error]);
|
||||
return NULL;
|
||||
}
|
||||
return task->response;
|
||||
}
|
||||
|
||||
void box_apple_http_task_cancel(box_apple_http_task_t *task) {
|
||||
if (task == NULL || task->task == NULL) {
|
||||
return;
|
||||
}
|
||||
NSURLSessionTask *nsTask = (__bridge NSURLSessionTask *)task->task;
|
||||
[nsTask cancel];
|
||||
}
|
||||
|
||||
void box_apple_http_task_close(box_apple_http_task_t *task) {
|
||||
if (task == NULL) {
|
||||
return;
|
||||
}
|
||||
if (task->task != NULL) {
|
||||
__unused NSURLSessionTask *nsTask = (__bridge_transfer NSURLSessionTask *)task->task;
|
||||
task->task = NULL;
|
||||
}
|
||||
if (task->done_semaphore != NULL) {
|
||||
__unused dispatch_semaphore_t doneSemaphore = (__bridge_transfer dispatch_semaphore_t)task->done_semaphore;
|
||||
task->done_semaphore = NULL;
|
||||
}
|
||||
free(task->error);
|
||||
free(task);
|
||||
}
|
||||
|
||||
void box_apple_http_response_free(box_apple_http_response_t *response) {
|
||||
if (response == NULL) {
|
||||
return;
|
||||
}
|
||||
for (size_t index = 0; index < response->header_count; index++) {
|
||||
free(response->header_keys[index]);
|
||||
free(response->header_values[index]);
|
||||
}
|
||||
free(response->header_keys);
|
||||
free(response->header_values);
|
||||
free(response->body);
|
||||
free(response->error);
|
||||
free(response);
|
||||
}
|
||||
@@ -0,0 +1,855 @@
|
||||
//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"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
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"
|
||||
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
|
||||
}
|
||||
|
||||
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{
|
||||
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 !strings.Contains(config.anchorPEM, "BEGIN CERTIFICATE") {
|
||||
t.Fatalf("unexpected anchor pem: %q", config.anchorPEM)
|
||||
}
|
||||
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.anchorPEM != "" {
|
||||
t.Fatalf("unexpected anchor pem: %q", config.anchorPEM)
|
||||
}
|
||||
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 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.Split(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 := 0; index < appleHTTPRecoveryLoops; index++ {
|
||||
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)
|
||||
if err := innerTransport.Close(); 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 := service.ContextWith[adapter.ConnectionManager](
|
||||
context.Background(),
|
||||
route.NewConnectionManager(log.NewNOPFactory().NewLogger("connection")),
|
||||
)
|
||||
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 (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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build !darwin || !cgo
|
||||
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sagernet/sing-box/option"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
func newAppleTransport(ctx context.Context, logger logger.ContextLogger, rawDialer N.Dialer, options option.HTTPClientOptions) (innerTransport, error) {
|
||||
return nil, E.New("Apple HTTP engine is not available on non-Apple platforms")
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/common/dialer"
|
||||
"github.com/sagernet/sing-box/common/tls"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
func NewTransport(ctx context.Context, logger logger.ContextLogger, tag string, options option.HTTPClientOptions) (*ManagedTransport, error) {
|
||||
rawDialer, err := dialer.NewWithOptions(dialer.Options{
|
||||
Context: ctx,
|
||||
Options: options.DialerOptions,
|
||||
RemoteIsDomain: true,
|
||||
DirectResolver: options.DirectResolver,
|
||||
ResolverOnDetour: options.ResolveOnDetour,
|
||||
NewDialer: options.ResolveOnDetour,
|
||||
DefaultOutbound: options.DefaultOutbound,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
headers := options.Headers.Build()
|
||||
host := headers.Get("Host")
|
||||
headers.Del("Host")
|
||||
|
||||
var cheapRebuild bool
|
||||
switch options.Engine {
|
||||
case C.TLSEngineApple:
|
||||
inner, transportErr := newAppleTransport(ctx, logger, rawDialer, options)
|
||||
if transportErr != nil {
|
||||
return nil, transportErr
|
||||
}
|
||||
managedTransport := &ManagedTransport{
|
||||
dialer: rawDialer,
|
||||
headers: headers,
|
||||
host: host,
|
||||
tag: tag,
|
||||
factory: func() (innerTransport, error) {
|
||||
return newAppleTransport(ctx, logger, rawDialer, options)
|
||||
},
|
||||
}
|
||||
managedTransport.epoch.Store(&transportEpoch{transport: inner})
|
||||
return managedTransport, nil
|
||||
case C.TLSEngineDefault, "go":
|
||||
cheapRebuild = true
|
||||
default:
|
||||
return nil, E.New("unknown HTTP engine: ", options.Engine)
|
||||
}
|
||||
tlsOptions := common.PtrValueOrDefault(options.TLS)
|
||||
tlsOptions.Enabled = true
|
||||
baseTLSConfig, err := tls.NewClientWithOptions(tls.ClientOptions{
|
||||
Context: ctx,
|
||||
Logger: logger,
|
||||
Options: tlsOptions,
|
||||
AllowEmptyServerName: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inner, err := newTransport(rawDialer, baseTLSConfig, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
managedTransport := &ManagedTransport{
|
||||
cheapRebuild: cheapRebuild,
|
||||
dialer: rawDialer,
|
||||
headers: headers,
|
||||
host: host,
|
||||
tag: tag,
|
||||
factory: func() (innerTransport, error) {
|
||||
return newTransport(rawDialer, baseTLSConfig, options)
|
||||
},
|
||||
}
|
||||
managedTransport.epoch.Store(&transportEpoch{transport: inner})
|
||||
return managedTransport, nil
|
||||
}
|
||||
|
||||
func newTransport(rawDialer N.Dialer, baseTLSConfig tls.Config, options option.HTTPClientOptions) (innerTransport, error) {
|
||||
version := options.Version
|
||||
if version == 0 {
|
||||
version = 2
|
||||
}
|
||||
fallbackDelay := time.Duration(options.DialerOptions.FallbackDelay)
|
||||
if fallbackDelay == 0 {
|
||||
fallbackDelay = 300 * time.Millisecond
|
||||
}
|
||||
var transport innerTransport
|
||||
var err error
|
||||
switch version {
|
||||
case 1:
|
||||
transport = newHTTP1Transport(rawDialer, baseTLSConfig)
|
||||
case 2:
|
||||
if options.DisableVersionFallback {
|
||||
transport, err = newHTTP2Transport(rawDialer, baseTLSConfig, options.HTTP2Options)
|
||||
} else {
|
||||
transport, err = newHTTP2FallbackTransport(rawDialer, baseTLSConfig, options.HTTP2Options)
|
||||
}
|
||||
case 3:
|
||||
if baseTLSConfig != nil {
|
||||
_, err = baseTLSConfig.STDConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if options.DisableVersionFallback {
|
||||
transport, err = newHTTP3Transport(rawDialer, baseTLSConfig, options.HTTP3Options)
|
||||
} else {
|
||||
var h2Fallback innerTransport
|
||||
h2Fallback, err = newHTTP2FallbackTransport(rawDialer, baseTLSConfig, options.HTTP2Options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport, err = newHTTP3FallbackTransport(rawDialer, baseTLSConfig, h2Fallback, options.HTTP3Options, fallbackDelay)
|
||||
}
|
||||
default:
|
||||
return nil, E.New("unknown HTTP version: ", version)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transport, nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package httpclient
|
||||
|
||||
import "context"
|
||||
|
||||
type transportKey struct{}
|
||||
|
||||
func contextWithTransportTag(ctx context.Context, transportTag string) context.Context {
|
||||
return context.WithValue(ctx, transportKey{}, transportTag)
|
||||
}
|
||||
|
||||
func transportTagFromContext(ctx context.Context) (string, bool) {
|
||||
value, loaded := ctx.Value(transportKey{}).(string)
|
||||
return value, loaded
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdTLS "crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/sing-box/common/tls"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
func dialTLS(ctx context.Context, rawDialer N.Dialer, baseTLSConfig tls.Config, destination M.Socksaddr, nextProtos []string, expectProto string) (net.Conn, error) {
|
||||
if baseTLSConfig == nil {
|
||||
return nil, E.New("TLS transport unavailable")
|
||||
}
|
||||
tlsConfig := baseTLSConfig.Clone()
|
||||
if tlsConfig.ServerName() == "" && destination.IsValid() {
|
||||
tlsConfig.SetServerName(destination.AddrString())
|
||||
}
|
||||
tlsConfig.SetNextProtos(nextProtos)
|
||||
conn, err := rawDialer.DialContext(ctx, N.NetworkTCP, destination)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsConn, err := tls.ClientHandshake(ctx, conn, tlsConfig)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if expectProto != "" && tlsConn.ConnectionState().NegotiatedProtocol != expectProto {
|
||||
tlsConn.Close()
|
||||
return nil, errHTTP2Fallback
|
||||
}
|
||||
return tlsConn, nil
|
||||
}
|
||||
|
||||
func applyHeaders(request *http.Request, headers http.Header, host string) {
|
||||
for header, values := range headers {
|
||||
request.Header[header] = append([]string(nil), values...)
|
||||
}
|
||||
if host != "" {
|
||||
request.Host = host
|
||||
}
|
||||
}
|
||||
|
||||
func requestRequiresHTTP1(request *http.Request) bool {
|
||||
return strings.Contains(strings.ToLower(request.Header.Get("Connection")), "upgrade") &&
|
||||
strings.EqualFold(request.Header.Get("Upgrade"), "websocket")
|
||||
}
|
||||
|
||||
func requestReplayable(request *http.Request) bool {
|
||||
return request.Body == nil || request.Body == http.NoBody || request.GetBody != nil
|
||||
}
|
||||
|
||||
func cloneRequestForRetry(request *http.Request) *http.Request {
|
||||
cloned := request.Clone(request.Context())
|
||||
if request.Body != nil && request.Body != http.NoBody && request.GetBody != nil {
|
||||
cloned.Body = mustGetBody(request)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func mustGetBody(request *http.Request) io.ReadCloser {
|
||||
body, err := request.GetBody()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func buildSTDTLSConfig(baseTLSConfig tls.Config, destination M.Socksaddr, nextProtos []string) (*stdTLS.Config, error) {
|
||||
if baseTLSConfig == nil {
|
||||
return nil, nil
|
||||
}
|
||||
tlsConfig := baseTLSConfig.Clone()
|
||||
if tlsConfig.ServerName() == "" && destination.IsValid() {
|
||||
tlsConfig.SetServerName(destination.AddrString())
|
||||
}
|
||||
tlsConfig.SetNextProtos(nextProtos)
|
||||
return tlsConfig.STDConfig()
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/sagernet/sing-box/common/tls"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
type http1Transport struct {
|
||||
transport *http.Transport
|
||||
}
|
||||
|
||||
func newHTTP1Transport(rawDialer N.Dialer, baseTLSConfig tls.Config) *http1Transport {
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return rawDialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
|
||||
},
|
||||
}
|
||||
if baseTLSConfig != nil {
|
||||
transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialTLS(ctx, rawDialer, baseTLSConfig, M.ParseSocksaddr(addr), []string{"http/1.1"}, "")
|
||||
}
|
||||
}
|
||||
return &http1Transport{transport: transport}
|
||||
}
|
||||
|
||||
func (t *http1Transport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return t.transport.RoundTrip(request)
|
||||
}
|
||||
|
||||
func (t *http1Transport) CloseIdleConnections() {
|
||||
t.transport.CloseIdleConnections()
|
||||
}
|
||||
|
||||
func (t *http1Transport) Close() error {
|
||||
t.CloseIdleConnections()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
stdTLS "crypto/tls"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/option"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
func CloneHTTP2Transport(transport *http2.Transport) *http2.Transport {
|
||||
return &http2.Transport{
|
||||
ReadIdleTimeout: transport.ReadIdleTimeout,
|
||||
PingTimeout: transport.PingTimeout,
|
||||
DialTLSContext: transport.DialTLSContext,
|
||||
}
|
||||
}
|
||||
|
||||
func ConfigureHTTP2Transport(options option.HTTP2Options) (*http2.Transport, error) {
|
||||
stdTransport := &http.Transport{
|
||||
TLSClientConfig: &stdTLS.Config{},
|
||||
HTTP2: &http.HTTP2Config{
|
||||
MaxReceiveBufferPerStream: int(options.StreamReceiveWindow.Value()),
|
||||
MaxReceiveBufferPerConnection: int(options.ConnectionReceiveWindow.Value()),
|
||||
MaxConcurrentStreams: options.MaxConcurrentStreams,
|
||||
SendPingTimeout: time.Duration(options.KeepAlivePeriod),
|
||||
PingTimeout: time.Duration(options.IdleTimeout),
|
||||
},
|
||||
}
|
||||
h2Transport, err := http2.ConfigureTransports(stdTransport)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "configure HTTP/2 transport")
|
||||
}
|
||||
// ConfigureTransports binds ConnPool to the throwaway http.Transport; sever it so DialTLSContext is used directly.
|
||||
h2Transport.ConnPool = nil
|
||||
h2Transport.ReadIdleTimeout = time.Duration(options.KeepAlivePeriod)
|
||||
h2Transport.PingTimeout = time.Duration(options.IdleTimeout)
|
||||
return h2Transport, nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdTLS "crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/sagernet/sing-box/common/tls"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
var errHTTP2Fallback = E.New("fallback to HTTP/1.1")
|
||||
|
||||
type http2FallbackTransport struct {
|
||||
h2Transport *http2.Transport
|
||||
h1Transport *http1Transport
|
||||
h2Fallback *atomic.Bool
|
||||
}
|
||||
|
||||
func newHTTP2FallbackTransport(rawDialer N.Dialer, baseTLSConfig tls.Config, options option.HTTP2Options) (*http2FallbackTransport, error) {
|
||||
h1 := newHTTP1Transport(rawDialer, baseTLSConfig)
|
||||
var fallback atomic.Bool
|
||||
h2Transport, err := ConfigureHTTP2Transport(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h2Transport.DialTLSContext = func(ctx context.Context, network, addr string, _ *stdTLS.Config) (net.Conn, error) {
|
||||
conn, dialErr := dialTLS(ctx, rawDialer, baseTLSConfig, M.ParseSocksaddr(addr), []string{http2.NextProtoTLS, "http/1.1"}, http2.NextProtoTLS)
|
||||
if dialErr != nil {
|
||||
if errors.Is(dialErr, errHTTP2Fallback) {
|
||||
fallback.Store(true)
|
||||
}
|
||||
return nil, dialErr
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
return &http2FallbackTransport{
|
||||
h2Transport: h2Transport,
|
||||
h1Transport: h1,
|
||||
h2Fallback: &fallback,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *http2FallbackTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return t.roundTrip(request, true)
|
||||
}
|
||||
|
||||
func (t *http2FallbackTransport) roundTrip(request *http.Request, allowHTTP1Fallback bool) (*http.Response, error) {
|
||||
if request.URL.Scheme != "https" || requestRequiresHTTP1(request) {
|
||||
return t.h1Transport.RoundTrip(request)
|
||||
}
|
||||
if t.h2Fallback.Load() {
|
||||
if !allowHTTP1Fallback {
|
||||
return nil, errHTTP2Fallback
|
||||
}
|
||||
return t.h1Transport.RoundTrip(request)
|
||||
}
|
||||
response, err := t.h2Transport.RoundTrip(request)
|
||||
if err == nil {
|
||||
return response, nil
|
||||
}
|
||||
if !errors.Is(err, errHTTP2Fallback) || !allowHTTP1Fallback {
|
||||
return nil, err
|
||||
}
|
||||
return t.h1Transport.RoundTrip(cloneRequestForRetry(request))
|
||||
}
|
||||
|
||||
func (t *http2FallbackTransport) CloseIdleConnections() {
|
||||
t.h1Transport.CloseIdleConnections()
|
||||
t.h2Transport.CloseIdleConnections()
|
||||
}
|
||||
|
||||
func (t *http2FallbackTransport) Close() error {
|
||||
t.CloseIdleConnections()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdTLS "crypto/tls"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/sagernet/sing-box/common/tls"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
type http2Transport struct {
|
||||
h2Transport *http2.Transport
|
||||
h1Transport *http1Transport
|
||||
}
|
||||
|
||||
func newHTTP2Transport(rawDialer N.Dialer, baseTLSConfig tls.Config, options option.HTTP2Options) (*http2Transport, error) {
|
||||
h1 := newHTTP1Transport(rawDialer, baseTLSConfig)
|
||||
h2Transport, err := ConfigureHTTP2Transport(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h2Transport.DialTLSContext = func(ctx context.Context, network, addr string, _ *stdTLS.Config) (net.Conn, error) {
|
||||
return dialTLS(ctx, rawDialer, baseTLSConfig, M.ParseSocksaddr(addr), []string{http2.NextProtoTLS}, http2.NextProtoTLS)
|
||||
}
|
||||
return &http2Transport{
|
||||
h2Transport: h2Transport,
|
||||
h1Transport: h1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *http2Transport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
if request.URL.Scheme != "https" || requestRequiresHTTP1(request) {
|
||||
return t.h1Transport.RoundTrip(request)
|
||||
}
|
||||
return t.h2Transport.RoundTrip(request)
|
||||
}
|
||||
|
||||
func (t *http2Transport) CloseIdleConnections() {
|
||||
t.h1Transport.CloseIdleConnections()
|
||||
t.h2Transport.CloseIdleConnections()
|
||||
}
|
||||
|
||||
func (t *http2Transport) Close() error {
|
||||
t.CloseIdleConnections()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
//go:build with_quic
|
||||
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdTLS "crypto/tls"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/quic-go"
|
||||
"github.com/sagernet/quic-go/http3"
|
||||
"github.com/sagernet/sing-box/common/tls"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common/bufio"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
type http3Transport struct {
|
||||
h3Transport *http3.Transport
|
||||
}
|
||||
|
||||
type http3FallbackTransport struct {
|
||||
h3Transport *http3.Transport
|
||||
h2Fallback innerTransport
|
||||
fallbackDelay time.Duration
|
||||
brokenAccess sync.Mutex
|
||||
brokenUntil time.Time
|
||||
brokenBackoff time.Duration
|
||||
}
|
||||
|
||||
func newHTTP3RoundTripper(
|
||||
rawDialer N.Dialer,
|
||||
baseTLSConfig tls.Config,
|
||||
options option.QUICOptions,
|
||||
) *http3.Transport {
|
||||
var handshakeTimeout time.Duration
|
||||
if baseTLSConfig != nil {
|
||||
handshakeTimeout = baseTLSConfig.HandshakeTimeout()
|
||||
}
|
||||
quicConfig := &quic.Config{
|
||||
InitialStreamReceiveWindow: options.StreamReceiveWindow.Value(),
|
||||
MaxStreamReceiveWindow: options.StreamReceiveWindow.Value(),
|
||||
InitialConnectionReceiveWindow: options.ConnectionReceiveWindow.Value(),
|
||||
MaxConnectionReceiveWindow: options.ConnectionReceiveWindow.Value(),
|
||||
KeepAlivePeriod: time.Duration(options.KeepAlivePeriod),
|
||||
MaxIdleTimeout: time.Duration(options.IdleTimeout),
|
||||
DisablePathMTUDiscovery: options.DisablePathMTUDiscovery,
|
||||
}
|
||||
if options.InitialPacketSize > 0 {
|
||||
quicConfig.InitialPacketSize = uint16(options.InitialPacketSize)
|
||||
}
|
||||
if options.MaxConcurrentStreams > 0 {
|
||||
quicConfig.MaxIncomingStreams = int64(options.MaxConcurrentStreams)
|
||||
}
|
||||
if handshakeTimeout > 0 {
|
||||
quicConfig.HandshakeIdleTimeout = handshakeTimeout
|
||||
}
|
||||
h3Transport := &http3.Transport{
|
||||
TLSClientConfig: &stdTLS.Config{},
|
||||
QUICConfig: quicConfig,
|
||||
Dial: func(ctx context.Context, addr string, tlsConfig *stdTLS.Config, quicConfig *quic.Config) (*quic.Conn, error) {
|
||||
if handshakeTimeout > 0 && quicConfig.HandshakeIdleTimeout == 0 {
|
||||
quicConfig = quicConfig.Clone()
|
||||
quicConfig.HandshakeIdleTimeout = handshakeTimeout
|
||||
}
|
||||
if baseTLSConfig != nil {
|
||||
var err error
|
||||
tlsConfig, err = buildSTDTLSConfig(baseTLSConfig, M.ParseSocksaddr(addr), []string{http3.NextProtoH3})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
tlsConfig = tlsConfig.Clone()
|
||||
tlsConfig.NextProtos = []string{http3.NextProtoH3}
|
||||
}
|
||||
conn, err := rawDialer.DialContext(ctx, N.NetworkUDP, M.ParseSocksaddr(addr))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quicConn, err := quic.DialEarly(ctx, bufio.NewUnbindPacketConn(conn), conn.RemoteAddr(), tlsConfig, quicConfig)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return quicConn, nil
|
||||
},
|
||||
}
|
||||
return h3Transport
|
||||
}
|
||||
|
||||
func newHTTP3Transport(
|
||||
rawDialer N.Dialer,
|
||||
baseTLSConfig tls.Config,
|
||||
options option.QUICOptions,
|
||||
) (innerTransport, error) {
|
||||
return &http3Transport{
|
||||
h3Transport: newHTTP3RoundTripper(rawDialer, baseTLSConfig, options),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newHTTP3FallbackTransport(
|
||||
rawDialer N.Dialer,
|
||||
baseTLSConfig tls.Config,
|
||||
h2Fallback innerTransport,
|
||||
options option.QUICOptions,
|
||||
fallbackDelay time.Duration,
|
||||
) (innerTransport, error) {
|
||||
return &http3FallbackTransport{
|
||||
h3Transport: newHTTP3RoundTripper(rawDialer, baseTLSConfig, options),
|
||||
h2Fallback: h2Fallback,
|
||||
fallbackDelay: fallbackDelay,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *http3Transport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return t.h3Transport.RoundTrip(request)
|
||||
}
|
||||
|
||||
func (t *http3Transport) CloseIdleConnections() {
|
||||
t.h3Transport.CloseIdleConnections()
|
||||
}
|
||||
|
||||
func (t *http3Transport) Close() error {
|
||||
t.CloseIdleConnections()
|
||||
return t.h3Transport.Close()
|
||||
}
|
||||
|
||||
func (t *http3FallbackTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
if request.URL.Scheme != "https" || requestRequiresHTTP1(request) {
|
||||
return t.h2Fallback.RoundTrip(request)
|
||||
}
|
||||
return t.roundTripHTTP3(request)
|
||||
}
|
||||
|
||||
func (t *http3FallbackTransport) roundTripHTTP3(request *http.Request) (*http.Response, error) {
|
||||
if t.h3Broken() {
|
||||
return t.h2FallbackRoundTrip(request)
|
||||
}
|
||||
response, err := t.h3Transport.RoundTripOpt(request, http3.RoundTripOpt{OnlyCachedConn: true})
|
||||
if err == nil {
|
||||
t.clearH3Broken()
|
||||
return response, nil
|
||||
}
|
||||
if !errors.Is(err, http3.ErrNoCachedConn) {
|
||||
t.markH3Broken()
|
||||
return t.h2FallbackRoundTrip(cloneRequestForRetry(request))
|
||||
}
|
||||
if !requestReplayable(request) {
|
||||
response, err = t.h3Transport.RoundTrip(request)
|
||||
if err == nil {
|
||||
t.clearH3Broken()
|
||||
return response, nil
|
||||
}
|
||||
t.markH3Broken()
|
||||
return nil, err
|
||||
}
|
||||
return t.roundTripHTTP3Race(request)
|
||||
}
|
||||
|
||||
func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request) (*http.Response, error) {
|
||||
ctx, cancel := context.WithCancel(request.Context())
|
||||
defer cancel()
|
||||
type result struct {
|
||||
response *http.Response
|
||||
err error
|
||||
h3 bool
|
||||
}
|
||||
results := make(chan result, 2)
|
||||
startRoundTrip := func(request *http.Request, useH3 bool) {
|
||||
request = request.WithContext(ctx)
|
||||
var (
|
||||
response *http.Response
|
||||
err error
|
||||
)
|
||||
if useH3 {
|
||||
response, err = t.h3Transport.RoundTrip(request)
|
||||
} else {
|
||||
response, err = t.h2FallbackRoundTrip(request)
|
||||
}
|
||||
results <- result{response: response, err: err, h3: useH3}
|
||||
}
|
||||
goroutines := 1
|
||||
received := 0
|
||||
drainRemaining := func() {
|
||||
cancel()
|
||||
for range goroutines - received {
|
||||
go func() {
|
||||
loser := <-results
|
||||
if loser.response != nil && loser.response.Body != nil {
|
||||
loser.response.Body.Close()
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
go startRoundTrip(cloneRequestForRetry(request), true)
|
||||
timer := time.NewTimer(t.fallbackDelay)
|
||||
defer timer.Stop()
|
||||
var (
|
||||
h3Err error
|
||||
fallbackErr error
|
||||
)
|
||||
for {
|
||||
select {
|
||||
case <-timer.C:
|
||||
if goroutines == 1 {
|
||||
goroutines++
|
||||
go startRoundTrip(cloneRequestForRetry(request), false)
|
||||
}
|
||||
case raceResult := <-results:
|
||||
received++
|
||||
if raceResult.err == nil {
|
||||
if raceResult.h3 {
|
||||
t.clearH3Broken()
|
||||
}
|
||||
drainRemaining()
|
||||
return raceResult.response, nil
|
||||
}
|
||||
if raceResult.h3 {
|
||||
t.markH3Broken()
|
||||
h3Err = raceResult.err
|
||||
if goroutines == 1 {
|
||||
goroutines++
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
go startRoundTrip(cloneRequestForRetry(request), false)
|
||||
}
|
||||
} else {
|
||||
fallbackErr = raceResult.err
|
||||
}
|
||||
if received < goroutines {
|
||||
continue
|
||||
}
|
||||
drainRemaining()
|
||||
switch {
|
||||
case h3Err != nil && fallbackErr != nil:
|
||||
return nil, E.Errors(h3Err, fallbackErr)
|
||||
case fallbackErr != nil:
|
||||
return nil, fallbackErr
|
||||
default:
|
||||
return nil, h3Err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *http3FallbackTransport) h2FallbackRoundTrip(request *http.Request) (*http.Response, error) {
|
||||
if fallback, isFallback := t.h2Fallback.(*http2FallbackTransport); isFallback {
|
||||
return fallback.roundTrip(request, true)
|
||||
}
|
||||
return t.h2Fallback.RoundTrip(request)
|
||||
}
|
||||
|
||||
func (t *http3FallbackTransport) CloseIdleConnections() {
|
||||
t.h3Transport.CloseIdleConnections()
|
||||
t.h2Fallback.CloseIdleConnections()
|
||||
}
|
||||
|
||||
func (t *http3FallbackTransport) Close() error {
|
||||
t.CloseIdleConnections()
|
||||
return t.h3Transport.Close()
|
||||
}
|
||||
|
||||
func (t *http3FallbackTransport) h3Broken() bool {
|
||||
t.brokenAccess.Lock()
|
||||
defer t.brokenAccess.Unlock()
|
||||
return !t.brokenUntil.IsZero() && time.Now().Before(t.brokenUntil)
|
||||
}
|
||||
|
||||
func (t *http3FallbackTransport) clearH3Broken() {
|
||||
t.brokenAccess.Lock()
|
||||
t.brokenUntil = time.Time{}
|
||||
t.brokenBackoff = 0
|
||||
t.brokenAccess.Unlock()
|
||||
}
|
||||
|
||||
func (t *http3FallbackTransport) markH3Broken() {
|
||||
t.brokenAccess.Lock()
|
||||
defer t.brokenAccess.Unlock()
|
||||
if t.brokenBackoff == 0 {
|
||||
t.brokenBackoff = 5 * time.Minute
|
||||
} else {
|
||||
t.brokenBackoff *= 2
|
||||
if t.brokenBackoff > 48*time.Hour {
|
||||
t.brokenBackoff = 48 * time.Hour
|
||||
}
|
||||
}
|
||||
t.brokenUntil = time.Now().Add(t.brokenBackoff)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//go:build !with_quic
|
||||
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/common/tls"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
func newHTTP3FallbackTransport(
|
||||
rawDialer N.Dialer,
|
||||
baseTLSConfig tls.Config,
|
||||
h2Fallback innerTransport,
|
||||
options option.QUICOptions,
|
||||
fallbackDelay time.Duration,
|
||||
) (innerTransport, error) {
|
||||
return nil, E.New("HTTP/3 requires building with the with_quic tag")
|
||||
}
|
||||
|
||||
func newHTTP3Transport(
|
||||
rawDialer N.Dialer,
|
||||
baseTLSConfig tls.Config,
|
||||
options option.QUICOptions,
|
||||
) (innerTransport, error) {
|
||||
return nil, E.New("HTTP/3 requires building with the with_quic tag")
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
type innerTransport interface {
|
||||
http.RoundTripper
|
||||
CloseIdleConnections()
|
||||
Close() error
|
||||
}
|
||||
|
||||
var _ adapter.HTTPTransport = (*ManagedTransport)(nil)
|
||||
|
||||
type ManagedTransport struct {
|
||||
epoch atomic.Pointer[transportEpoch]
|
||||
rebuildAccess sync.Mutex
|
||||
factory func() (innerTransport, error)
|
||||
cheapRebuild bool
|
||||
|
||||
dialer N.Dialer
|
||||
headers http.Header
|
||||
host string
|
||||
tag string
|
||||
}
|
||||
|
||||
type transportEpoch struct {
|
||||
transport innerTransport
|
||||
active atomic.Int64
|
||||
marked atomic.Bool
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
type managedResponseBody struct {
|
||||
body io.ReadCloser
|
||||
release func()
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (e *transportEpoch) tryClose() {
|
||||
e.closeOnce.Do(func() {
|
||||
e.transport.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func (b *managedResponseBody) Read(p []byte) (int, error) {
|
||||
return b.body.Read(p)
|
||||
}
|
||||
|
||||
func (b *managedResponseBody) Close() error {
|
||||
err := b.body.Close()
|
||||
b.once.Do(b.release)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *ManagedTransport) getEpoch() (*transportEpoch, error) {
|
||||
epoch := t.epoch.Load()
|
||||
if epoch != nil {
|
||||
return epoch, nil
|
||||
}
|
||||
t.rebuildAccess.Lock()
|
||||
defer t.rebuildAccess.Unlock()
|
||||
epoch = t.epoch.Load()
|
||||
if epoch != nil {
|
||||
return epoch, nil
|
||||
}
|
||||
inner, err := t.factory()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
epoch = &transportEpoch{transport: inner}
|
||||
t.epoch.Store(epoch)
|
||||
return epoch, nil
|
||||
}
|
||||
|
||||
func (t *ManagedTransport) acquireEpoch() (*transportEpoch, error) {
|
||||
for {
|
||||
epoch, err := t.getEpoch()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
epoch.active.Add(1)
|
||||
if epoch == t.epoch.Load() {
|
||||
return epoch, nil
|
||||
}
|
||||
t.releaseEpoch(epoch)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ManagedTransport) releaseEpoch(epoch *transportEpoch) {
|
||||
if epoch.active.Add(-1) == 0 && epoch.marked.Load() {
|
||||
epoch.tryClose()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ManagedTransport) retireEpoch(epoch *transportEpoch) {
|
||||
if epoch == nil {
|
||||
return
|
||||
}
|
||||
epoch.marked.Store(true)
|
||||
if epoch.active.Load() == 0 {
|
||||
epoch.tryClose()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ManagedTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
epoch, err := t.acquireEpoch()
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "rebuild http transport")
|
||||
}
|
||||
if t.tag != "" {
|
||||
if transportTag, loaded := transportTagFromContext(request.Context()); loaded && transportTag == t.tag {
|
||||
t.releaseEpoch(epoch)
|
||||
return nil, E.New("HTTP request loopback in transport[", t.tag, "]")
|
||||
}
|
||||
request = request.Clone(contextWithTransportTag(request.Context(), t.tag))
|
||||
} else if len(t.headers) > 0 || t.host != "" {
|
||||
request = request.Clone(request.Context())
|
||||
}
|
||||
applyHeaders(request, t.headers, t.host)
|
||||
response, roundTripErr := epoch.transport.RoundTrip(request)
|
||||
if roundTripErr != nil || response == nil || response.Body == nil {
|
||||
t.releaseEpoch(epoch)
|
||||
return response, roundTripErr
|
||||
}
|
||||
response.Body = &managedResponseBody{
|
||||
body: response.Body,
|
||||
release: func() { t.releaseEpoch(epoch) },
|
||||
}
|
||||
return response, roundTripErr
|
||||
}
|
||||
|
||||
func (t *ManagedTransport) CloseIdleConnections() {
|
||||
oldEpoch := t.epoch.Swap(nil)
|
||||
if oldEpoch == nil {
|
||||
return
|
||||
}
|
||||
oldEpoch.transport.CloseIdleConnections()
|
||||
t.retireEpoch(oldEpoch)
|
||||
}
|
||||
|
||||
func (t *ManagedTransport) Reset() {
|
||||
oldEpoch := t.epoch.Swap(nil)
|
||||
if t.cheapRebuild {
|
||||
t.rebuildAccess.Lock()
|
||||
if t.epoch.Load() == nil {
|
||||
inner, err := t.factory()
|
||||
if err == nil {
|
||||
t.epoch.Store(&transportEpoch{transport: inner})
|
||||
}
|
||||
}
|
||||
t.rebuildAccess.Unlock()
|
||||
}
|
||||
t.retireEpoch(oldEpoch)
|
||||
}
|
||||
|
||||
func (t *ManagedTransport) close() error {
|
||||
epoch := t.epoch.Swap(nil)
|
||||
if epoch != nil {
|
||||
return epoch.transport.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ adapter.HTTPTransport = (*sharedRef)(nil)
|
||||
|
||||
type sharedRef struct {
|
||||
managed *ManagedTransport
|
||||
shared *sharedState
|
||||
idle atomic.Bool
|
||||
}
|
||||
|
||||
type sharedState struct {
|
||||
activeRefs atomic.Int32
|
||||
}
|
||||
|
||||
func newSharedRef(managed *ManagedTransport, shared *sharedState) *sharedRef {
|
||||
shared.activeRefs.Add(1)
|
||||
return &sharedRef{
|
||||
managed: managed,
|
||||
shared: shared,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *sharedRef) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
if r.idle.CompareAndSwap(true, false) {
|
||||
r.shared.activeRefs.Add(1)
|
||||
}
|
||||
return r.managed.RoundTrip(request)
|
||||
}
|
||||
|
||||
func (r *sharedRef) CloseIdleConnections() {
|
||||
if r.idle.CompareAndSwap(false, true) {
|
||||
if r.shared.activeRefs.Add(-1) == 0 {
|
||||
r.managed.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *sharedRef) Reset() {
|
||||
r.managed.Reset()
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
_ adapter.HTTPClientManager = (*Manager)(nil)
|
||||
_ adapter.LifecycleService = (*Manager)(nil)
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
ctx context.Context
|
||||
logger log.ContextLogger
|
||||
access sync.Mutex
|
||||
defines map[string]option.HTTPClient
|
||||
sharedTransports map[string]*sharedManagedTransport
|
||||
managedTransports []*ManagedTransport
|
||||
defaultTag string
|
||||
defaultTransport *sharedManagedTransport
|
||||
defaultTransportFallback func() (*ManagedTransport, error)
|
||||
}
|
||||
|
||||
type sharedManagedTransport struct {
|
||||
managed *ManagedTransport
|
||||
shared *sharedState
|
||||
}
|
||||
|
||||
func NewManager(ctx context.Context, logger log.ContextLogger, clients []option.HTTPClient, defaultHTTPClient string) *Manager {
|
||||
defines := make(map[string]option.HTTPClient, len(clients))
|
||||
for _, client := range clients {
|
||||
defines[client.Tag] = client
|
||||
}
|
||||
defaultTag := defaultHTTPClient
|
||||
if defaultTag == "" && len(clients) > 0 {
|
||||
defaultTag = clients[0].Tag
|
||||
}
|
||||
return &Manager{
|
||||
ctx: ctx,
|
||||
logger: logger,
|
||||
defines: defines,
|
||||
sharedTransports: make(map[string]*sharedManagedTransport),
|
||||
defaultTag: defaultTag,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Initialize(defaultTransportFallback func() (*ManagedTransport, error)) {
|
||||
m.defaultTransportFallback = defaultTransportFallback
|
||||
}
|
||||
|
||||
func (m *Manager) Name() string {
|
||||
return "http-client"
|
||||
}
|
||||
|
||||
func (m *Manager) Start(stage adapter.StartStage) error {
|
||||
if stage != adapter.StartStateStart {
|
||||
return nil
|
||||
}
|
||||
if m.defaultTag != "" {
|
||||
sharedTransport, err := m.resolveShared(m.defaultTag)
|
||||
if err != nil {
|
||||
return E.Cause(err, "resolve default http client")
|
||||
}
|
||||
m.defaultTransport = sharedTransport
|
||||
} else if m.defaultTransportFallback != nil {
|
||||
transport, err := m.defaultTransportFallback()
|
||||
if err != nil {
|
||||
return E.Cause(err, "create default http client")
|
||||
}
|
||||
m.trackTransport(transport)
|
||||
m.defaultTransport = &sharedManagedTransport{
|
||||
managed: transport,
|
||||
shared: &sharedState{},
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) DefaultTransport() adapter.HTTPTransport {
|
||||
if m.defaultTransport == nil {
|
||||
return nil
|
||||
}
|
||||
return newSharedRef(m.defaultTransport.managed, m.defaultTransport.shared)
|
||||
}
|
||||
|
||||
func (m *Manager) ResolveTransport(ctx context.Context, logger logger.ContextLogger, options option.HTTPClientOptions) (adapter.HTTPTransport, error) {
|
||||
if options.Tag != "" {
|
||||
if options.ResolveOnDetour {
|
||||
define, loaded := m.defines[options.Tag]
|
||||
if !loaded {
|
||||
return nil, E.New("http_client not found: ", options.Tag)
|
||||
}
|
||||
resolvedOptions := define.Options()
|
||||
resolvedOptions.ResolveOnDetour = true
|
||||
transport, err := NewTransport(ctx, logger, options.Tag, resolvedOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.trackTransport(transport)
|
||||
return transport, nil
|
||||
}
|
||||
sharedTransport, err := m.resolveShared(options.Tag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newSharedRef(sharedTransport.managed, sharedTransport.shared), nil
|
||||
}
|
||||
transport, err := NewTransport(ctx, logger, "", options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.trackTransport(transport)
|
||||
return transport, nil
|
||||
}
|
||||
|
||||
func (m *Manager) trackTransport(transport *ManagedTransport) {
|
||||
m.access.Lock()
|
||||
defer m.access.Unlock()
|
||||
m.managedTransports = append(m.managedTransports, transport)
|
||||
}
|
||||
|
||||
func (m *Manager) resolveShared(tag string) (*sharedManagedTransport, error) {
|
||||
m.access.Lock()
|
||||
defer m.access.Unlock()
|
||||
if sharedTransport, loaded := m.sharedTransports[tag]; loaded {
|
||||
return sharedTransport, nil
|
||||
}
|
||||
define, loaded := m.defines[tag]
|
||||
if !loaded {
|
||||
return nil, E.New("http_client not found: ", tag)
|
||||
}
|
||||
transport, err := NewTransport(m.ctx, m.logger, tag, define.Options())
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "create shared http_client[", tag, "]")
|
||||
}
|
||||
sharedTransport := &sharedManagedTransport{
|
||||
managed: transport,
|
||||
shared: &sharedState{},
|
||||
}
|
||||
m.sharedTransports[tag] = sharedTransport
|
||||
m.managedTransports = append(m.managedTransports, transport)
|
||||
return sharedTransport, nil
|
||||
}
|
||||
|
||||
func (m *Manager) ResetNetwork() {
|
||||
m.access.Lock()
|
||||
defer m.access.Unlock()
|
||||
for _, transport := range m.managedTransports {
|
||||
transport.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Close() error {
|
||||
m.access.Lock()
|
||||
defer m.access.Unlock()
|
||||
if m.managedTransports == nil {
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
for _, transport := range m.managedTransports {
|
||||
err = E.Append(err, transport.close(), func(err error) error {
|
||||
return E.Cause(err, "close http client")
|
||||
})
|
||||
}
|
||||
m.managedTransports = nil
|
||||
m.sharedTransports = nil
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user