draft: cronet HTTP client & Accept ntp time

This commit is contained in:
世界
2026-04-14 14:04:35 +08:00
parent e4811b611f
commit c33a2bd3e9
15 changed files with 440 additions and 23 deletions

View File

@@ -22,6 +22,7 @@ import (
"strings"
"sync"
"sync/atomic"
"time"
"unsafe"
"github.com/sagernet/sing-box/adapter"
@@ -32,6 +33,7 @@ import (
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
@@ -72,6 +74,7 @@ type appleTransportShared struct {
logger logger.ContextLogger
bridge *proxybridge.Bridge
config appleSessionConfig
timeFunc func() time.Time
refs atomic.Int32
}
@@ -99,6 +102,7 @@ func newAppleTransport(ctx context.Context, logger logger.ContextLogger, rawDial
logger: logger,
bridge: bridge,
config: sessionConfig,
timeFunc: ntp.TimeFuncFromContext(ctx),
}
shared.refs.Store(1)
session, err := shared.newSession()
@@ -279,14 +283,24 @@ func (t *appleTransport) RoundTrip(request *http.Request) (*http.Response, error
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)),
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

View File

@@ -28,6 +28,8 @@ typedef struct box_apple_http_request {
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 {

View File

@@ -18,6 +18,8 @@ typedef struct box_apple_http_task {
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;
@@ -72,10 +74,13 @@ static NSArray *box_parse_certificates_from_pem(const char *pem, size_t pem_len)
return certificates;
}
static bool box_evaluate_trust(SecTrustRef trustRef, NSArray *anchors, bool anchor_only) {
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) {
@@ -93,6 +98,17 @@ static bool box_evaluate_trust(SecTrustRef trustRef, NSArray *anchors, bool anch
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;
@@ -148,22 +164,15 @@ didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge
completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil);
return;
}
BOOL needsCustomHandling = self.insecure || self.anchorOnly || self.anchors.count > 0 || self.pinnedPublicKeyHashes.length > 0;
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) {
if (self.anchorOnly || self.anchors.count > 0) {
ok = box_evaluate_trust(trustRef, self.anchors, self.anchorOnly);
} else {
CFErrorRef error = NULL;
ok = SecTrustEvaluateWithError(trustRef, &error);
if (error != NULL) {
CFRelease(error);
}
}
ok = box_evaluate_trust(trustRef, self.anchors, self.anchorOnly, verifyDate);
}
if (ok && self.pinnedPublicKeyHashes.length > 0) {
CFArrayRef certificateChain = SecTrustCopyCertificateChain(trustRef);
@@ -306,6 +315,9 @@ box_apple_http_task_t *box_apple_http_session_send_async(
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;

View File

@@ -53,6 +53,21 @@ func NewTransport(ctx context.Context, logger logger.ContextLogger, tag string,
host: host,
tag: tag,
}, nil
case C.TLSEngineCronet:
transport, transportErr := newCronetTransport(ctx, logger, rawDialer, options)
if transportErr != nil {
return nil, transportErr
}
headers := options.Headers.Build()
host := headers.Get("Host")
headers.Del("Host")
return &Transport{
transport: transport,
dialer: rawDialer,
headers: headers,
host: host,
tag: tag,
}, nil
case C.TLSEngineDefault, "go":
default:
return nil, E.New("unknown HTTP engine: ", options.Engine)

View File

@@ -0,0 +1,17 @@
//go:build !with_naive_outbound
package httpclient
import (
"context"
"github.com/sagernet/sing-box/adapter"
"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 newCronetTransport(ctx context.Context, logger logger.ContextLogger, rawDialer N.Dialer, options option.HTTPClientOptions) (adapter.HTTPTransport, error) {
return nil, E.New("Cronet HTTP engine is not available on current platform/build")
}

View File

@@ -0,0 +1,249 @@
//go:build with_naive_outbound
package httpclient
import (
"context"
"encoding/pem"
"net/http"
"os"
"strings"
"sync/atomic"
"time"
"github.com/sagernet/cronet-go"
_ "github.com/sagernet/cronet-go/all"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/dialer"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
singLogger "github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/service"
mDNS "github.com/miekg/dns"
)
type cronetTransportShared struct {
roundTripper *cronet.RoundTripper
refs atomic.Int32
}
type cronetTransport struct {
shared *cronetTransportShared
}
func newCronetTransport(ctx context.Context, logger singLogger.ContextLogger, rawDialer N.Dialer, options option.HTTPClientOptions) (adapter.HTTPTransport, error) {
version := options.Version
if version == 0 {
version = 2
}
switch version {
case 1, 2, 3:
default:
return nil, E.New("unknown HTTP version: ", version)
}
if options.DisableVersionFallback && version != 3 {
return nil, E.New("disable_version_fallback is unsupported in Cronet HTTP engine unless version is 3")
}
switch version {
case 1:
if options.HTTP2Options != (option.HTTP2Options{}) {
return nil, E.New("HTTP/2 options are unsupported in Cronet HTTP engine for HTTP/1.1")
}
if options.HTTP3Options != (option.QUICOptions{}) {
return nil, E.New("QUIC options are unsupported in Cronet HTTP engine for HTTP/1.1")
}
case 2:
if options.HTTP2Options.IdleTimeout != 0 ||
options.HTTP2Options.KeepAlivePeriod != 0 ||
options.HTTP2Options.MaxConcurrentStreams > 0 {
return nil, E.New("selected HTTP/2 options are unsupported in Cronet HTTP engine")
}
case 3:
if options.HTTP3Options.KeepAlivePeriod != 0 ||
options.HTTP3Options.InitialPacketSize > 0 ||
options.HTTP3Options.DisablePathMTUDiscovery ||
options.HTTP3Options.MaxConcurrentStreams > 0 {
return nil, E.New("selected QUIC options are unsupported in Cronet HTTP engine")
}
}
tlsOptions := common.PtrValueOrDefault(options.TLS)
if tlsOptions.Engine != "" {
return nil, E.New("tls.engine is unsupported in Cronet HTTP engine")
}
if tlsOptions.DisableSNI {
return nil, E.New("disable_sni is unsupported in Cronet HTTP engine")
}
if tlsOptions.Insecure {
return nil, E.New("tls.insecure is unsupported in Cronet HTTP engine")
}
if len(tlsOptions.ALPN) > 0 {
return nil, E.New("tls.alpn is unsupported in Cronet HTTP engine")
}
if tlsOptions.MinVersion != "" || tlsOptions.MaxVersion != "" {
return nil, E.New("tls.min_version/max_version is unsupported in Cronet HTTP engine")
}
if len(tlsOptions.CipherSuites) > 0 {
return nil, E.New("cipher_suites is unsupported in Cronet HTTP engine")
}
if len(tlsOptions.CurvePreferences) > 0 {
return nil, E.New("curve_preferences is unsupported in Cronet HTTP engine")
}
if len(tlsOptions.ClientCertificate) > 0 || len(tlsOptions.ClientCertificatePath) > 0 {
return nil, E.New("client certificate is unsupported in Cronet HTTP engine")
}
if len(tlsOptions.ClientKey) > 0 || tlsOptions.ClientKeyPath != "" {
return nil, E.New("client key is unsupported in Cronet HTTP engine")
}
if tlsOptions.Fragment || tlsOptions.RecordFragment {
return nil, E.New("tls fragment is unsupported in Cronet HTTP engine")
}
if tlsOptions.KernelTx || tlsOptions.KernelRx {
return nil, E.New("ktls is unsupported in Cronet HTTP engine")
}
if tlsOptions.HandshakeTimeout != 0 {
return nil, E.New("tls.handshake_timeout is unsupported in Cronet HTTP engine")
}
if tlsOptions.UTLS != nil && tlsOptions.UTLS.Enabled {
return nil, E.New("utls is unsupported in Cronet HTTP engine")
}
if tlsOptions.Reality != nil && tlsOptions.Reality.Enabled {
return nil, E.New("reality is unsupported in Cronet HTTP engine")
}
trustedRootCertificates, err := readCronetRootCertificates(tlsOptions)
if err != nil {
return nil, err
}
echEnabled, echConfigList, echQueryServerName, err := readCronetECH(tlsOptions)
if err != nil {
return nil, err
}
dnsResolver := newCronetDNSResolver(ctx, logger, rawDialer)
if echEnabled && dnsResolver == nil {
return nil, E.New("ECH requires a configured DNS resolver in Cronet HTTP engine")
}
roundTripper, err := cronet.NewRoundTripper(cronet.RoundTripperOptions{
Logger: logger,
Version: version,
DisableVersionFallback: options.DisableVersionFallback,
Dialer: rawDialer,
DNSResolver: dnsResolver,
TLS: cronet.RoundTripperTLSOptions{
ServerName: tlsOptions.ServerName,
TrustedRootCertificates: trustedRootCertificates,
PinnedPublicKeySHA256: tlsOptions.CertificatePublicKeySHA256,
ECHEnabled: echEnabled,
ECHConfigList: echConfigList,
ECHQueryServerName: echQueryServerName,
},
HTTP2: cronet.RoundTripperHTTP2Options{
StreamReceiveWindow: options.HTTP2Options.StreamReceiveWindow.Value(),
ConnectionReceiveWindow: options.HTTP2Options.ConnectionReceiveWindow.Value(),
},
QUIC: cronet.RoundTripperQUICOptions{
StreamReceiveWindow: options.HTTP3Options.StreamReceiveWindow.Value(),
ConnectionReceiveWindow: options.HTTP3Options.ConnectionReceiveWindow.Value(),
IdleTimeout: time.Duration(options.HTTP3Options.IdleTimeout),
},
})
if err != nil {
return nil, err
}
shared := &cronetTransportShared{
roundTripper: roundTripper,
}
shared.refs.Store(1)
return &cronetTransport{shared: shared}, nil
}
func readCronetRootCertificates(tlsOptions option.OutboundTLSOptions) (string, error) {
if len(tlsOptions.Certificate) > 0 {
return strings.Join(tlsOptions.Certificate, "\n"), nil
}
if tlsOptions.CertificatePath == "" {
return "", nil
}
content, err := os.ReadFile(tlsOptions.CertificatePath)
if err != nil {
return "", E.Cause(err, "read certificate")
}
return string(content), nil
}
func readCronetECH(tlsOptions option.OutboundTLSOptions) (bool, []byte, string, error) {
if tlsOptions.ECH == nil || !tlsOptions.ECH.Enabled {
return false, nil, "", nil
}
//nolint:staticcheck
if tlsOptions.ECH.PQSignatureSchemesEnabled || tlsOptions.ECH.DynamicRecordSizingDisabled {
return false, nil, "", E.New("legacy ECH options are deprecated in sing-box 1.12.0 and removed in sing-box 1.13.0")
}
var echConfig []byte
if len(tlsOptions.ECH.Config) > 0 {
echConfig = []byte(strings.Join(tlsOptions.ECH.Config, "\n"))
} else if tlsOptions.ECH.ConfigPath != "" {
content, err := os.ReadFile(tlsOptions.ECH.ConfigPath)
if err != nil {
return false, nil, "", E.Cause(err, "read ECH config")
}
echConfig = content
}
if len(echConfig) == 0 {
return true, nil, tlsOptions.ECH.QueryServerName, nil
}
block, rest := pem.Decode(echConfig)
if block == nil || block.Type != "ECH CONFIGS" || len(rest) > 0 {
return false, nil, "", E.New("invalid ECH configs pem")
}
return true, block.Bytes, tlsOptions.ECH.QueryServerName, nil
}
func newCronetDNSResolver(ctx context.Context, logger singLogger.ContextLogger, rawDialer N.Dialer) cronet.DNSResolverFunc {
resolveDialer, isResolveDialer := rawDialer.(dialer.ResolveDialer)
if !isResolveDialer {
return nil
}
dnsRouter := service.FromContext[adapter.DNSRouter](ctx)
if dnsRouter == nil {
return nil
}
queryOptions := resolveDialer.QueryOptions()
return func(dnsContext context.Context, request *mDNS.Msg) *mDNS.Msg {
response, err := dnsRouter.Exchange(dnsContext, request, queryOptions)
if err == nil {
return response
}
logger.Error("DNS exchange failed: ", err)
failure := new(mDNS.Msg)
failure.SetReply(request)
failure.Rcode = mDNS.RcodeServerFailure
return failure
}
}
func (t *cronetTransport) RoundTrip(request *http.Request) (*http.Response, error) {
return t.shared.roundTripper.RoundTrip(request)
}
func (t *cronetTransport) CloseIdleConnections() {
t.shared.roundTripper.CloseIdleConnections()
}
func (t *cronetTransport) Clone() adapter.HTTPTransport {
t.shared.refs.Add(1)
return &cronetTransport{shared: t.shared}
}
func (t *cronetTransport) Close() error {
if t.shared.refs.Add(-1) == 0 {
return t.shared.roundTripper.Close()
}
return nil
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
"github.com/sagernet/sing/common/ntp"
"github.com/sagernet/sing/service"
)
@@ -32,6 +33,7 @@ type appleClientConfig struct {
anchorPEM string
anchorOnly bool
certificatePublicKeySHA256 [][]byte
timeFunc func() time.Time
}
func (c *appleClientConfig) ServerName() string {
@@ -77,6 +79,7 @@ func (c *appleClientConfig) Clone() Config {
anchorPEM: c.anchorPEM,
anchorOnly: c.anchorOnly,
certificatePublicKeySHA256: append([][]byte(nil), c.certificatePublicKeySHA256...),
timeFunc: c.timeFunc,
}
}
@@ -113,6 +116,7 @@ func newAppleClient(ctx context.Context, logger logger.ContextLogger, serverAddr
anchorPEM: validated.AnchorPEM,
anchorOnly: validated.AnchorOnly,
certificatePublicKeySHA256: append([][]byte(nil), options.CertificatePublicKeySHA256...),
timeFunc: ntp.TimeFuncFromContext(ctx),
}, nil
}

View File

@@ -64,6 +64,15 @@ func (c *appleClientConfig) ClientHandshake(ctx context.Context, conn net.Conn)
anchorPEMPtr := cStringOrNil(c.anchorPEM)
defer cFree(anchorPEMPtr)
var (
hasVerifyTime bool
verifyTimeUnixMilli int64
)
if c.timeFunc != nil {
hasVerifyTime = true
verifyTimeUnixMilli = c.timeFunc().UnixMilli()
}
var errorPtr *C.char
client := C.box_apple_tls_client_create(
C.int(dupFD),
@@ -76,6 +85,8 @@ func (c *appleClientConfig) ClientHandshake(ctx context.Context, conn net.Conn)
anchorPEMPtr,
C.size_t(len(c.anchorPEM)),
C.bool(c.anchorOnly),
C.bool(hasVerifyTime),
C.int64_t(verifyTimeUnixMilli),
&errorPtr,
)
if client == nil {

View File

@@ -25,6 +25,8 @@ box_apple_tls_client_t *box_apple_tls_client_create(
const char *anchor_pem,
size_t anchor_pem_len,
bool anchor_only,
bool has_verify_time,
int64_t verify_time_unix_millis,
char **error_out
);

View File

@@ -208,12 +208,16 @@ static NSArray *box_parse_certificates_from_pem(const char *pem, size_t pem_len)
return certificates;
}
static bool box_evaluate_trust(sec_trust_t trust, NSArray *anchors, bool anchor_only) {
static bool box_evaluate_trust(sec_trust_t trust, NSArray *anchors, bool anchor_only, NSDate *verify_date) {
bool result = false;
SecTrustRef trustRef = sec_trust_copy_ref(trust);
if (trustRef == NULL) {
return false;
}
if (verify_date != nil && SecTrustSetVerifyDate(trustRef, (__bridge CFDateRef)verify_date) != errSecSuccess) {
CFRelease(trustRef);
return false;
}
if (anchors.count > 0 || anchor_only) {
CFMutableArrayRef anchorArray = CFArrayCreateMutable(NULL, 0, &kCFTypeArrayCallBacks);
for (id certificate in anchors) {
@@ -347,6 +351,8 @@ box_apple_tls_client_t *box_apple_tls_client_create(
const char *anchor_pem,
size_t anchor_pem_len,
bool anchor_only,
bool has_verify_time,
int64_t verify_time_unix_millis,
char **error_out
) {
box_apple_tls_client_t *client = calloc(1, sizeof(box_apple_tls_client_t));
@@ -363,6 +369,10 @@ box_apple_tls_client_t *box_apple_tls_client_create(
NSArray<NSString *> *alpnList = box_split_lines(alpn, alpn_len);
NSArray *anchors = box_parse_certificates_from_pem(anchor_pem, anchor_pem_len);
NSDate *verifyDate = nil;
if (has_verify_time) {
verifyDate = [NSDate dateWithTimeIntervalSince1970:(NSTimeInterval)verify_time_unix_millis / 1000.0];
}
nw_parameters_t parameters = nw_parameters_create_secure_tcp(^(nw_protocol_options_t tls_options) {
sec_protocol_options_t sec_options = nw_tls_copy_sec_protocol_options(tls_options);
if (min_version != 0) {
@@ -382,9 +392,9 @@ box_apple_tls_client_t *box_apple_tls_client_create(
sec_protocol_options_set_verify_block(sec_options, ^(sec_protocol_metadata_t metadata, sec_trust_t trust, sec_protocol_verify_complete_t complete) {
complete(true);
}, box_apple_tls_client_queue(client));
} else if (anchors.count > 0 || anchor_only) {
} else if (verifyDate != nil || anchors.count > 0 || anchor_only) {
sec_protocol_options_set_verify_block(sec_options, ^(sec_protocol_metadata_t metadata, sec_trust_t trust, sec_protocol_verify_complete_t complete) {
complete(box_evaluate_trust(trust, anchors, anchor_only));
complete(box_evaluate_trust(trust, anchors, anchor_only, verifyDate));
}, box_apple_tls_client_queue(client));
}
}, NW_PARAMETERS_DEFAULT_CONFIGURATION);

View File

@@ -5,4 +5,5 @@ const ACMETLS1Protocol = "acme-tls/1"
const (
TLSEngineDefault = ""
TLSEngineApple = "apple"
TLSEngineCronet = "cronet"
)

View File

@@ -37,6 +37,7 @@ Values:
* `go` (default)
* `apple`
* `cronet`
`apple` uses NSURLSession, only available on Apple platforms.
@@ -73,6 +74,45 @@ Unsupported fields:
* `tls.utls`
* `tls.reality`
`cronet` uses Cronet, only available when sing-box is built with the `with_naive_outbound` tag.
Supported fields:
* `version`
* `disable_version_fallback` (`version = 3` only)
* `headers`
* `tls.server_name` (must match request host)
* `tls.certificate` / `tls.certificate_path`
* `tls.certificate_public_key_sha256`
* `tls.ech.config` / `tls.ech.config_path` / `tls.ech.query_server_name`
* `http2.stream_receive_window` / `http2.connection_receive_window`
* `quic.stream_receive_window` / `quic.connection_receive_window`
* `quic.idle_timeout`
* Dial Fields
Unsupported fields:
* `tls.engine`
* `tls.disable_sni`
* `tls.insecure`
* `tls.alpn`
* `tls.min_version` / `tls.max_version`
* `tls.cipher_suites`
* `tls.curve_preferences`
* `tls.client_certificate` / `tls.client_certificate_path` / `tls.client_key` / `tls.client_key_path`
* `tls.fragment` / `tls.record_fragment`
* `tls.kernel_tx` / `tls.kernel_rx`
* `tls.handshake_timeout`
* `tls.utls`
* `tls.reality`
* `http2.idle_timeout`
* `http2.keep_alive_period`
* `http2.max_concurrent_streams`
* `quic.keep_alive_period`
* `quic.initial_packet_size`
* `quic.disable_path_mtu_discovery`
* `quic.max_concurrent_streams`
#### version
HTTP version.

View File

@@ -37,6 +37,7 @@ icon: material/new-box
* `go`(默认)
* `apple`
* `cronet`
`apple` 使用 NSURLSession仅在 Apple 平台可用。
@@ -73,6 +74,45 @@ icon: material/new-box
* `tls.utls`
* `tls.reality`
`cronet` 使用 Cronet仅在 sing-box 使用 `with_naive_outbound` 标签构建时可用。
支持的字段:
* `version`
* `disable_version_fallback`(仅 `version = 3`
* `headers`
* `tls.server_name`(必须与请求主机匹配)
* `tls.certificate` / `tls.certificate_path`
* `tls.certificate_public_key_sha256`
* `tls.ech.config` / `tls.ech.config_path` / `tls.ech.query_server_name`
* `http2.stream_receive_window` / `http2.connection_receive_window`
* `quic.stream_receive_window` / `quic.connection_receive_window`
* `quic.idle_timeout`
* 拨号字段
不支持的字段:
* `tls.engine`
* `tls.disable_sni`
* `tls.insecure`
* `tls.alpn`
* `tls.min_version` / `tls.max_version`
* `tls.cipher_suites`
* `tls.curve_preferences`
* `tls.client_certificate` / `tls.client_certificate_path` / `tls.client_key` / `tls.client_key_path`
* `tls.fragment` / `tls.record_fragment`
* `tls.kernel_tx` / `tls.kernel_rx`
* `tls.handshake_timeout`
* `tls.utls`
* `tls.reality`
* `http2.idle_timeout`
* `http2.keep_alive_period`
* `http2.max_concurrent_streams`
* `quic.keep_alive_period`
* `quic.initial_packet_size`
* `quic.disable_path_mtu_discovery`
* `quic.max_concurrent_streams`
#### version
HTTP 版本。

2
go.mod
View File

@@ -66,6 +66,8 @@ require (
howett.net/plist v1.0.1
)
replace github.com/sagernet/cronet-go => ../cronet-go
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/ajg/form v1.5.1 // indirect

2
go.sum
View File

@@ -168,8 +168,6 @@ github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a h1:+NkI2670SQpQWvkk
github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a/go.mod h1:63s7jpZqcDAIpj8oI/1v4Izok+npJOHACFCU6+huCkM=
github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ=
github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI=
github.com/sagernet/cronet-go v0.0.0-20260410123506-30af64155529 h1:tG9OIgS2yHlPAV3JdeUxsRB5v/G+SLKJpxqzpp6k8Oo=
github.com/sagernet/cronet-go v0.0.0-20260410123506-30af64155529/go.mod h1:hwFHBEjjthyEquDULbr4c4ucMedp8Drb6Jvm2kt/0Bw=
github.com/sagernet/cronet-go/all v0.0.0-20260410123506-30af64155529 h1:lVtf0GEzHsM7kB8pOgLMXDtiHRdLd9YHJxiFJYA+UtY=
github.com/sagernet/cronet-go/all v0.0.0-20260410123506-30af64155529/go.mod h1:9ojC4hR6aYIFjEJYlWDdVsirXLugrOW+ww6n4qoU4rk=
github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260410122836-cce5e03076fc h1:kIrWkB7LXP+ff8d93ZgRJxY3CMMDCQ3UpJXXCtCN7g4=