diff --git a/common/tls/ech.go b/common/tls/ech.go index de911126..61d2a209 100644 --- a/common/tls/ech.go +++ b/common/tls/ech.go @@ -25,7 +25,7 @@ import ( "golang.org/x/crypto/cryptobyte" ) -func parseECHClientConfig(ctx context.Context, options option.OutboundTLSOptions, tlsConfig *tls.Config) (Config, error) { +func parseECHClientConfig(ctx context.Context, stdConfig *STDClientConfig, options option.OutboundTLSOptions) (Config, error) { var echConfig []byte if len(options.ECH.Config) > 0 { echConfig = []byte(strings.Join(options.ECH.Config, "\n")) @@ -45,11 +45,11 @@ func parseECHClientConfig(ctx context.Context, options option.OutboundTLSOptions if block == nil || block.Type != "ECH CONFIGS" || len(rest) > 0 { return nil, E.New("invalid ECH configs pem") } - tlsConfig.EncryptedClientHelloConfigList = block.Bytes - return &STDClientConfig{tlsConfig}, nil + stdConfig.config.EncryptedClientHelloConfigList = block.Bytes + return stdConfig, nil } else { return &STDECHClientConfig{ - STDClientConfig: STDClientConfig{tlsConfig}, + STDClientConfig: stdConfig, dnsRouter: service.FromContext[adapter.DNSRouter](ctx), }, nil } @@ -103,7 +103,7 @@ func reloadECHKeys(echKeyPath string, tlsConfig *tls.Config) error { } type STDECHClientConfig struct { - STDClientConfig + *STDClientConfig access sync.Mutex dnsRouter adapter.DNSRouter lastTTL time.Duration @@ -171,7 +171,7 @@ func (s *STDECHClientConfig) fetchAndHandshake(ctx context.Context, conn net.Con } func (s *STDECHClientConfig) Clone() Config { - return &STDECHClientConfig{STDClientConfig: STDClientConfig{s.config.Clone()}, dnsRouter: s.dnsRouter, lastUpdate: s.lastUpdate} + return &STDECHClientConfig{STDClientConfig: s.STDClientConfig.Clone().(*STDClientConfig), dnsRouter: s.dnsRouter, lastUpdate: s.lastUpdate} } func UnmarshalECHKeys(raw []byte) ([]tls.EncryptedClientHelloKey, error) { diff --git a/common/tls/std_client.go b/common/tls/std_client.go index 8e9327bf..49b239a9 100644 --- a/common/tls/std_client.go +++ b/common/tls/std_client.go @@ -7,15 +7,21 @@ import ( "net" "os" "strings" + "time" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tlsfragment" "github.com/sagernet/sing-box/option" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/ntp" ) type STDClientConfig struct { - config *tls.Config + ctx context.Context + config *tls.Config + fragment bool + fragmentFallbackDelay time.Duration + recordFragment bool } func (s *STDClientConfig) ServerName() string { @@ -39,11 +45,14 @@ func (s *STDClientConfig) Config() (*STDConfig, error) { } func (s *STDClientConfig) Client(conn net.Conn) (Conn, error) { + if s.recordFragment { + conn = tf.NewConn(conn, s.ctx, s.fragment, s.recordFragment, s.fragmentFallbackDelay) + } return tls.Client(conn, s.config), nil } func (s *STDClientConfig) Clone() Config { - return &STDClientConfig{s.config.Clone()} + return &STDClientConfig{s.ctx, s.config.Clone(), s.fragment, s.fragmentFallbackDelay, s.recordFragment} } func NewSTDClient(ctx context.Context, serverAddress string, options option.OutboundTLSOptions) (Config, error) { @@ -127,8 +136,10 @@ func NewSTDClient(ctx context.Context, serverAddress string, options option.Outb } tlsConfig.RootCAs = certPool } + stdConfig := &STDClientConfig{ctx, &tlsConfig, options.Fragment, time.Duration(options.FragmentFallbackDelay), options.RecordFragment} if options.ECH != nil && options.ECH.Enabled { - return parseECHClientConfig(ctx, options, &tlsConfig) + return parseECHClientConfig(ctx, stdConfig, options) + } else { + return stdConfig, nil } - return &STDClientConfig{&tlsConfig}, nil } diff --git a/common/tls/utls_client.go b/common/tls/utls_client.go index 5c0de6ad..f9a61cd4 100644 --- a/common/tls/utls_client.go +++ b/common/tls/utls_client.go @@ -11,8 +11,10 @@ import ( "net/netip" "os" "strings" + "time" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tlsfragment" "github.com/sagernet/sing-box/option" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/ntp" @@ -22,8 +24,12 @@ import ( ) type UTLSClientConfig struct { - config *utls.Config - id utls.ClientHelloID + ctx context.Context + config *utls.Config + id utls.ClientHelloID + fragment bool + fragmentFallbackDelay time.Duration + recordFragment bool } func (e *UTLSClientConfig) ServerName() string { @@ -50,6 +56,9 @@ func (e *UTLSClientConfig) Config() (*STDConfig, error) { } func (e *UTLSClientConfig) Client(conn net.Conn) (Conn, error) { + if e.recordFragment { + conn = tf.NewConn(conn, e.ctx, e.fragment, e.recordFragment, e.fragmentFallbackDelay) + } return &utlsALPNWrapper{utlsConnWrapper{utls.UClient(conn, e.config.Clone(), e.id)}, e.config.NextProtos}, nil } @@ -59,8 +68,7 @@ func (e *UTLSClientConfig) SetSessionIDGenerator(generator func(clientHello []by func (e *UTLSClientConfig) Clone() Config { return &UTLSClientConfig{ - config: e.config.Clone(), - id: e.id, + e.ctx, e.config.Clone(), e.id, e.fragment, e.fragmentFallbackDelay, e.recordFragment, } } @@ -192,7 +200,7 @@ func NewUTLSClient(ctx context.Context, serverAddress string, options option.Out if err != nil { return nil, err } - return &UTLSClientConfig{&tlsConfig, id}, nil + return &UTLSClientConfig{ctx, &tlsConfig, id, options.Fragment, time.Duration(options.FragmentFallbackDelay), options.RecordFragment}, nil } var ( diff --git a/common/tlsfragment/conn.go b/common/tlsfragment/conn.go index 50baa6ba..7c2123a4 100644 --- a/common/tlsfragment/conn.go +++ b/common/tlsfragment/conn.go @@ -9,6 +9,7 @@ import ( "strings" "time" + C "github.com/sagernet/sing-box/constant" N "github.com/sagernet/sing/common/network" "golang.org/x/net/publicsuffix" @@ -19,16 +20,21 @@ type Conn struct { tcpConn *net.TCPConn ctx context.Context firstPacketWritten bool + splitPacket bool splitRecord bool fallbackDelay time.Duration } -func NewConn(conn net.Conn, ctx context.Context, splitRecord bool, fallbackDelay time.Duration) *Conn { +func NewConn(conn net.Conn, ctx context.Context, splitPacket bool, splitRecord bool, fallbackDelay time.Duration) *Conn { + if fallbackDelay == 0 { + fallbackDelay = C.TLSFragmentFallbackDelay + } tcpConn, _ := N.UnwrapReader(conn).(*net.TCPConn) return &Conn{ Conn: conn, tcpConn: tcpConn, ctx: ctx, + splitPacket: splitPacket, splitRecord: splitRecord, fallbackDelay: fallbackDelay, } @@ -41,7 +47,7 @@ func (c *Conn) Write(b []byte) (n int, err error) { }() serverName := indexTLSServerName(b) if serverName != nil { - if !c.splitRecord { + if c.splitPacket { if c.tcpConn != nil { err = c.tcpConn.SetNoDelay(true) if err != nil { @@ -81,33 +87,41 @@ func (c *Conn) Write(b []byte) (n int, err error) { payload = b[splitIndexes[i-1]:splitIndexes[i]] } if c.splitRecord { + if c.splitPacket { + buffer.Reset() + } payloadLen := uint16(len(payload)) buffer.Write(b[:3]) binary.Write(&buffer, binary.BigEndian, payloadLen) buffer.Write(payload) - } else if c.tcpConn != nil && i != len(splitIndexes) { - err = writeAndWaitAck(c.ctx, c.tcpConn, payload, c.fallbackDelay) - if err != nil { - return + if c.splitPacket { + payload = buffer.Bytes() } - } else { - _, err = c.Conn.Write(payload) - if err != nil { - return + } + if c.splitPacket { + if c.tcpConn != nil && i != len(splitIndexes) { + err = writeAndWaitAck(c.ctx, c.tcpConn, payload, c.fallbackDelay) + if err != nil { + return + } + } else { + _, err = c.Conn.Write(payload) + if err != nil { + return + } } } } - if c.splitRecord { + if c.splitRecord && !c.splitPacket { _, err = c.Conn.Write(buffer.Bytes()) if err != nil { return } - } else { - if c.tcpConn != nil { - err = c.tcpConn.SetNoDelay(false) - if err != nil { - return - } + } + if c.tcpConn != nil { + err = c.tcpConn.SetNoDelay(false) + if err != nil { + return } } return len(b), nil diff --git a/common/tlsfragment/conn_test.go b/common/tlsfragment/conn_test.go index 21e2fcb2..b7ade873 100644 --- a/common/tlsfragment/conn_test.go +++ b/common/tlsfragment/conn_test.go @@ -15,7 +15,7 @@ func TestTLSFragment(t *testing.T) { t.Parallel() tcpConn, err := net.Dial("tcp", "1.1.1.1:443") require.NoError(t, err) - tlsConn := tls.Client(tf.NewConn(tcpConn, context.Background(), false, 0), &tls.Config{ + tlsConn := tls.Client(tf.NewConn(tcpConn, context.Background(), true, false, 0), &tls.Config{ ServerName: "www.cloudflare.com", }) require.NoError(t, tlsConn.Handshake()) @@ -25,7 +25,17 @@ func TestTLSRecordFragment(t *testing.T) { t.Parallel() tcpConn, err := net.Dial("tcp", "1.1.1.1:443") require.NoError(t, err) - tlsConn := tls.Client(tf.NewConn(tcpConn, context.Background(), true, 0), &tls.Config{ + tlsConn := tls.Client(tf.NewConn(tcpConn, context.Background(), false, true, 0), &tls.Config{ + ServerName: "www.cloudflare.com", + }) + require.NoError(t, tlsConn.Handshake()) +} + +func TestTLS2Fragment(t *testing.T) { + t.Parallel() + tcpConn, err := net.Dial("tcp", "1.1.1.1:443") + require.NoError(t, err) + tlsConn := tls.Client(tf.NewConn(tcpConn, context.Background(), true, true, 0), &tls.Config{ ServerName: "www.cloudflare.com", }) require.NoError(t, tlsConn.Handshake()) diff --git a/docs/configuration/route/rule_action.md b/docs/configuration/route/rule_action.md index 1bba9542..c975af2b 100644 --- a/docs/configuration/route/rule_action.md +++ b/docs/configuration/route/rule_action.md @@ -172,14 +172,12 @@ and should not be used to circumvent real censorship. Due to poor performance, try `tls_record_fragment` first, and only apply to server names known to be blocked. On Linux, Apple platforms, (administrator privileges required) Windows, -the wait time can be automatically detected, otherwise it will fall back to +the wait time can be automatically detected. Otherwise, it will fall back to waiting for a fixed time specified by `tls_fragment_fallback_delay`. In addition, if the actual wait time is less than 20ms, it will also fall back to waiting for a fixed time, because the target is considered to be local or behind a transparent proxy. -Conflict with `tls_record_fragment`. - #### tls_fragment_fallback_delay !!! question "Since sing-box 1.12.0" @@ -194,11 +192,6 @@ The fallback value used when TLS segmentation cannot automatically determine the Fragment TLS handshake into multiple TLS records to bypass firewalls. -This feature is intended to circumvent simple firewalls based on **plaintext packet matching**, -and should not be used to circumvent real censorship. - -Conflict with `tls_fragment`. - ### sniff ```json diff --git a/docs/configuration/route/rule_action.zh.md b/docs/configuration/route/rule_action.zh.md index e1626963..0081e827 100644 --- a/docs/configuration/route/rule_action.zh.md +++ b/docs/configuration/route/rule_action.zh.md @@ -170,8 +170,6 @@ UDP 连接超时时间。 此外,若实际等待时间小于 20 毫秒,同样会回退至固定等待时间模式,因为此时判定目标处于本地或透明代理之后。 -与 `tls_record_fragment` 冲突。 - #### tls_fragment_fallback_delay !!! question "自 sing-box 1.12.0 起" @@ -186,10 +184,6 @@ UDP 连接超时时间。 通过分段 TLS 握手数据包到多个 TLS 记录来绕过防火墙检测。 -此功能旨在规避基于**明文数据包匹配**的简单防火墙,不应该用于规避真的审查。 - -与 `tls_fragment` 冲突。 - ### sniff ```json diff --git a/docs/configuration/shared/tls.md b/docs/configuration/shared/tls.md index e4d77718..6fe74846 100644 --- a/docs/configuration/shared/tls.md +++ b/docs/configuration/shared/tls.md @@ -4,6 +4,9 @@ icon: material/alert-decagram !!! quote "Changes in sing-box 1.12.0" + :material-plus: [fragment](#fragment) + :material-plus: [fragment_fallback_delay](#fragment_fallback_delay) + :material-plus: [record_fragment](#record_fragment) :material-delete-clock: [ech.pq_signature_schemes_enabled](#pq_signature_schemes_enabled) :material-delete-clock: [ech.dynamic_record_sizing_disabled](#dynamic_record_sizing_disabled) @@ -82,6 +85,9 @@ icon: material/alert-decagram "cipher_suites": [], "certificate": "", "certificate_path": "", + "fragment": false, + "fragment_fallback_delay": "", + "record_fragment": false, "ech": { "enabled": false, "config": [], @@ -313,6 +319,44 @@ The path to ECH configuration, in PEM format. If empty, load from DNS will be attempted. +#### fragment + +!!! question "Since sing-box 1.12.0" + +==Client only== + +Fragment TLS handshakes to bypass firewalls. + +This feature is intended to circumvent simple firewalls based on **plaintext packet matching**, +and should not be used to circumvent real censorship. + +Due to poor performance, try `record_fragment` first, and only apply to server names known to be blocked. + +On Linux, Apple platforms, (administrator privileges required) Windows, +the wait time can be automatically detected. Otherwise, it will fall back to +waiting for a fixed time specified by `fragment_fallback_delay`. + +In addition, if the actual wait time is less than 20ms, it will also fall back to waiting for a fixed time, +because the target is considered to be local or behind a transparent proxy. + +#### fragment_fallback_delay + +!!! question "Since sing-box 1.12.0" + +==Client only== + +The fallback value used when TLS segmentation cannot automatically determine the wait time. + +`500ms` is used by default. + +#### record_fragment + +!!! question "Since sing-box 1.12.0" + +==Client only== + +Fragment TLS handshake into multiple TLS records to bypass firewalls. + ### ACME Fields #### domain diff --git a/docs/configuration/shared/tls.zh.md b/docs/configuration/shared/tls.zh.md index 1418dd59..b85d3290 100644 --- a/docs/configuration/shared/tls.zh.md +++ b/docs/configuration/shared/tls.zh.md @@ -4,6 +4,9 @@ icon: material/alert-decagram !!! quote "sing-box 1.12.0 中的更改" + :material-plus: [tls_fragment](#tls_fragment) + :material-plus: [tls_fragment_fallback_delay](#tls_fragment_fallback_delay) + :material-plus: [tls_record_fragment](#tls_record_fragment) :material-delete-clock: [ech.pq_signature_schemes_enabled](#pq_signature_schemes_enabled) :material-delete-clock: [ech.dynamic_record_sizing_disabled](#dynamic_record_sizing_disabled) @@ -82,6 +85,9 @@ icon: material/alert-decagram "cipher_suites": [], "certificate": [], "certificate_path": "", + "fragment": false, + "fragment_fallback_delay": "", + "record_fragment": false, "ech": { "enabled": false, "pq_signature_schemes_enabled": false, @@ -305,6 +311,41 @@ ECH PEM 配置路径 如果为 true,则始终使用最大可能的 TLS 记录大小。 如果为 false,则可能会调整 TLS 记录的大小以尝试改善延迟。 +#### tls_fragment + +!!! question "自 sing-box 1.12.0 起" + +==仅客户端== + +通过分段 TLS 握手数据包来绕过防火墙检测。 + +此功能旨在规避基于**明文数据包匹配**的简单防火墙,不应该用于规避真的审查。 + +由于性能不佳,请首先尝试 `tls_record_fragment`,且仅应用于已知被阻止的服务器名称。 + +在 Linux、Apple 平台和需要管理员权限的 Windows 系统上,可自动检测等待时间。 +若无法自动检测,将回退使用 `tls_fragment_fallback_delay` 指定的固定等待时间。 + +此外,若实际等待时间小于 20 毫秒,同样会回退至固定等待时间模式,因为此时判定目标处于本地或透明代理之后。 + +#### tls_fragment_fallback_delay + +!!! question "自 sing-box 1.12.0 起" + +==仅客户端== + +当 TLS 分片功能无法自动判定等待时间时使用的回退值。 + +默认使用 `500ms`。 + +#### tls_record_fragment + +==仅客户端== + +!!! question "自 sing-box 1.12.0 起" + +通过分段 TLS 握手数据包到多个 TLS 记录来绕过防火墙检测。 + ### ACME 字段 #### domain diff --git a/option/tls.go b/option/tls.go index 13e75306..1c09527c 100644 --- a/option/tls.go +++ b/option/tls.go @@ -37,19 +37,22 @@ func (o *InboundTLSOptionsContainer) ReplaceInboundTLSOptions(options *InboundTL } type OutboundTLSOptions struct { - Enabled bool `json:"enabled,omitempty"` - DisableSNI bool `json:"disable_sni,omitempty"` - ServerName string `json:"server_name,omitempty"` - Insecure bool `json:"insecure,omitempty"` - ALPN badoption.Listable[string] `json:"alpn,omitempty"` - MinVersion string `json:"min_version,omitempty"` - MaxVersion string `json:"max_version,omitempty"` - CipherSuites badoption.Listable[string] `json:"cipher_suites,omitempty"` - Certificate badoption.Listable[string] `json:"certificate,omitempty"` - CertificatePath string `json:"certificate_path,omitempty"` - ECH *OutboundECHOptions `json:"ech,omitempty"` - UTLS *OutboundUTLSOptions `json:"utls,omitempty"` - Reality *OutboundRealityOptions `json:"reality,omitempty"` + Enabled bool `json:"enabled,omitempty"` + DisableSNI bool `json:"disable_sni,omitempty"` + ServerName string `json:"server_name,omitempty"` + Insecure bool `json:"insecure,omitempty"` + ALPN badoption.Listable[string] `json:"alpn,omitempty"` + MinVersion string `json:"min_version,omitempty"` + MaxVersion string `json:"max_version,omitempty"` + CipherSuites badoption.Listable[string] `json:"cipher_suites,omitempty"` + Certificate badoption.Listable[string] `json:"certificate,omitempty"` + CertificatePath string `json:"certificate_path,omitempty"` + Fragment bool `json:"fragment,omitempty"` + FragmentFallbackDelay badoption.Duration `json:"fragment_fallback_delay,omitempty"` + RecordFragment bool `json:"record_fragment,omitempty"` + ECH *OutboundECHOptions `json:"ech,omitempty"` + UTLS *OutboundUTLSOptions `json:"utls,omitempty"` + Reality *OutboundRealityOptions `json:"reality,omitempty"` } type OutboundTLSOptionsContainer struct { diff --git a/route/conn.go b/route/conn.go index d5f914b8..0318e98d 100644 --- a/route/conn.go +++ b/route/conn.go @@ -90,14 +90,8 @@ func (m *ConnectionManager) NewConnection(ctx context.Context, this N.Dialer, co m.logger.ErrorContext(ctx, err) return } - if metadata.TLSFragment { - fallbackDelay := metadata.TLSFragmentFallbackDelay - if fallbackDelay == 0 { - fallbackDelay = C.TLSFragmentFallbackDelay - } - remoteConn = tf.NewConn(remoteConn, ctx, false, fallbackDelay) - } else if metadata.TLSRecordFragment { - remoteConn = tf.NewConn(remoteConn, ctx, true, 0) + if metadata.TLSFragment || metadata.TLSRecordFragment { + remoteConn = tf.NewConn(remoteConn, ctx, metadata.TLSFragment, metadata.TLSRecordFragment, metadata.TLSFragmentFallbackDelay) } m.access.Lock() element := m.connections.PushBack(conn)