Improve TLS fragments

This commit is contained in:
世界 2025-06-12 08:58:07 +08:00
parent e7ae3ddf31
commit 0a5f09f147
No known key found for this signature in database
GPG Key ID: CD109927C34A63C4
11 changed files with 181 additions and 69 deletions

View File

@ -25,7 +25,7 @@ import (
"golang.org/x/crypto/cryptobyte" "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 var echConfig []byte
if len(options.ECH.Config) > 0 { if len(options.ECH.Config) > 0 {
echConfig = []byte(strings.Join(options.ECH.Config, "\n")) 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 { if block == nil || block.Type != "ECH CONFIGS" || len(rest) > 0 {
return nil, E.New("invalid ECH configs pem") return nil, E.New("invalid ECH configs pem")
} }
tlsConfig.EncryptedClientHelloConfigList = block.Bytes stdConfig.config.EncryptedClientHelloConfigList = block.Bytes
return &STDClientConfig{tlsConfig}, nil return stdConfig, nil
} else { } else {
return &STDECHClientConfig{ return &STDECHClientConfig{
STDClientConfig: STDClientConfig{tlsConfig}, STDClientConfig: stdConfig,
dnsRouter: service.FromContext[adapter.DNSRouter](ctx), dnsRouter: service.FromContext[adapter.DNSRouter](ctx),
}, nil }, nil
} }
@ -103,7 +103,7 @@ func reloadECHKeys(echKeyPath string, tlsConfig *tls.Config) error {
} }
type STDECHClientConfig struct { type STDECHClientConfig struct {
STDClientConfig *STDClientConfig
access sync.Mutex access sync.Mutex
dnsRouter adapter.DNSRouter dnsRouter adapter.DNSRouter
lastTTL time.Duration lastTTL time.Duration
@ -171,7 +171,7 @@ func (s *STDECHClientConfig) fetchAndHandshake(ctx context.Context, conn net.Con
} }
func (s *STDECHClientConfig) Clone() Config { 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) { func UnmarshalECHKeys(raw []byte) ([]tls.EncryptedClientHelloKey, error) {

View File

@ -7,15 +7,21 @@ import (
"net" "net"
"os" "os"
"strings" "strings"
"time"
"github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/tlsfragment"
"github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions" E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/ntp" "github.com/sagernet/sing/common/ntp"
) )
type STDClientConfig struct { type STDClientConfig struct {
config *tls.Config ctx context.Context
config *tls.Config
fragment bool
fragmentFallbackDelay time.Duration
recordFragment bool
} }
func (s *STDClientConfig) ServerName() string { func (s *STDClientConfig) ServerName() string {
@ -39,11 +45,14 @@ func (s *STDClientConfig) Config() (*STDConfig, error) {
} }
func (s *STDClientConfig) Client(conn net.Conn) (Conn, 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 return tls.Client(conn, s.config), nil
} }
func (s *STDClientConfig) Clone() Config { 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) { 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 tlsConfig.RootCAs = certPool
} }
stdConfig := &STDClientConfig{ctx, &tlsConfig, options.Fragment, time.Duration(options.FragmentFallbackDelay), options.RecordFragment}
if options.ECH != nil && options.ECH.Enabled { 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
} }

View File

@ -11,8 +11,10 @@ import (
"net/netip" "net/netip"
"os" "os"
"strings" "strings"
"time"
"github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/tlsfragment"
"github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions" E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/ntp" "github.com/sagernet/sing/common/ntp"
@ -22,8 +24,12 @@ import (
) )
type UTLSClientConfig struct { type UTLSClientConfig struct {
config *utls.Config ctx context.Context
id utls.ClientHelloID config *utls.Config
id utls.ClientHelloID
fragment bool
fragmentFallbackDelay time.Duration
recordFragment bool
} }
func (e *UTLSClientConfig) ServerName() string { func (e *UTLSClientConfig) ServerName() string {
@ -50,6 +56,9 @@ func (e *UTLSClientConfig) Config() (*STDConfig, error) {
} }
func (e *UTLSClientConfig) Client(conn net.Conn) (Conn, 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 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 { func (e *UTLSClientConfig) Clone() Config {
return &UTLSClientConfig{ return &UTLSClientConfig{
config: e.config.Clone(), e.ctx, e.config.Clone(), e.id, e.fragment, e.fragmentFallbackDelay, e.recordFragment,
id: e.id,
} }
} }
@ -192,7 +200,7 @@ func NewUTLSClient(ctx context.Context, serverAddress string, options option.Out
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &UTLSClientConfig{&tlsConfig, id}, nil return &UTLSClientConfig{ctx, &tlsConfig, id, options.Fragment, time.Duration(options.FragmentFallbackDelay), options.RecordFragment}, nil
} }
var ( var (

View File

@ -9,6 +9,7 @@ import (
"strings" "strings"
"time" "time"
C "github.com/sagernet/sing-box/constant"
N "github.com/sagernet/sing/common/network" N "github.com/sagernet/sing/common/network"
"golang.org/x/net/publicsuffix" "golang.org/x/net/publicsuffix"
@ -19,16 +20,21 @@ type Conn struct {
tcpConn *net.TCPConn tcpConn *net.TCPConn
ctx context.Context ctx context.Context
firstPacketWritten bool firstPacketWritten bool
splitPacket bool
splitRecord bool splitRecord bool
fallbackDelay time.Duration 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) tcpConn, _ := N.UnwrapReader(conn).(*net.TCPConn)
return &Conn{ return &Conn{
Conn: conn, Conn: conn,
tcpConn: tcpConn, tcpConn: tcpConn,
ctx: ctx, ctx: ctx,
splitPacket: splitPacket,
splitRecord: splitRecord, splitRecord: splitRecord,
fallbackDelay: fallbackDelay, fallbackDelay: fallbackDelay,
} }
@ -41,7 +47,7 @@ func (c *Conn) Write(b []byte) (n int, err error) {
}() }()
serverName := indexTLSServerName(b) serverName := indexTLSServerName(b)
if serverName != nil { if serverName != nil {
if !c.splitRecord { if c.splitPacket {
if c.tcpConn != nil { if c.tcpConn != nil {
err = c.tcpConn.SetNoDelay(true) err = c.tcpConn.SetNoDelay(true)
if err != nil { if err != nil {
@ -81,33 +87,41 @@ func (c *Conn) Write(b []byte) (n int, err error) {
payload = b[splitIndexes[i-1]:splitIndexes[i]] payload = b[splitIndexes[i-1]:splitIndexes[i]]
} }
if c.splitRecord { if c.splitRecord {
if c.splitPacket {
buffer.Reset()
}
payloadLen := uint16(len(payload)) payloadLen := uint16(len(payload))
buffer.Write(b[:3]) buffer.Write(b[:3])
binary.Write(&buffer, binary.BigEndian, payloadLen) binary.Write(&buffer, binary.BigEndian, payloadLen)
buffer.Write(payload) buffer.Write(payload)
} else if c.tcpConn != nil && i != len(splitIndexes) { if c.splitPacket {
err = writeAndWaitAck(c.ctx, c.tcpConn, payload, c.fallbackDelay) payload = buffer.Bytes()
if err != nil {
return
} }
} else { }
_, err = c.Conn.Write(payload) if c.splitPacket {
if err != nil { if c.tcpConn != nil && i != len(splitIndexes) {
return 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()) _, err = c.Conn.Write(buffer.Bytes())
if err != nil { if err != nil {
return return
} }
} else { }
if c.tcpConn != nil { if c.tcpConn != nil {
err = c.tcpConn.SetNoDelay(false) err = c.tcpConn.SetNoDelay(false)
if err != nil { if err != nil {
return return
}
} }
} }
return len(b), nil return len(b), nil

View File

@ -15,7 +15,7 @@ func TestTLSFragment(t *testing.T) {
t.Parallel() t.Parallel()
tcpConn, err := net.Dial("tcp", "1.1.1.1:443") tcpConn, err := net.Dial("tcp", "1.1.1.1:443")
require.NoError(t, err) 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", ServerName: "www.cloudflare.com",
}) })
require.NoError(t, tlsConn.Handshake()) require.NoError(t, tlsConn.Handshake())
@ -25,7 +25,17 @@ func TestTLSRecordFragment(t *testing.T) {
t.Parallel() t.Parallel()
tcpConn, err := net.Dial("tcp", "1.1.1.1:443") tcpConn, err := net.Dial("tcp", "1.1.1.1:443")
require.NoError(t, err) 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", ServerName: "www.cloudflare.com",
}) })
require.NoError(t, tlsConn.Handshake()) require.NoError(t, tlsConn.Handshake())

View File

@ -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. 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, 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`. 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, 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. because the target is considered to be local or behind a transparent proxy.
Conflict with `tls_record_fragment`.
#### tls_fragment_fallback_delay #### tls_fragment_fallback_delay
!!! question "Since sing-box 1.12.0" !!! 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. 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 ### sniff
```json ```json

View File

@ -170,8 +170,6 @@ UDP 连接超时时间。
此外,若实际等待时间小于 20 毫秒,同样会回退至固定等待时间模式,因为此时判定目标处于本地或透明代理之后。 此外,若实际等待时间小于 20 毫秒,同样会回退至固定等待时间模式,因为此时判定目标处于本地或透明代理之后。
`tls_record_fragment` 冲突。
#### tls_fragment_fallback_delay #### tls_fragment_fallback_delay
!!! question "自 sing-box 1.12.0 起" !!! question "自 sing-box 1.12.0 起"
@ -186,10 +184,6 @@ UDP 连接超时时间。
通过分段 TLS 握手数据包到多个 TLS 记录来绕过防火墙检测。 通过分段 TLS 握手数据包到多个 TLS 记录来绕过防火墙检测。
此功能旨在规避基于**明文数据包匹配**的简单防火墙,不应该用于规避真的审查。
`tls_fragment` 冲突。
### sniff ### sniff
```json ```json

View File

@ -4,6 +4,9 @@ icon: material/alert-decagram
!!! quote "Changes in sing-box 1.12.0" !!! 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.pq_signature_schemes_enabled](#pq_signature_schemes_enabled)
:material-delete-clock: [ech.dynamic_record_sizing_disabled](#dynamic_record_sizing_disabled) :material-delete-clock: [ech.dynamic_record_sizing_disabled](#dynamic_record_sizing_disabled)
@ -82,6 +85,9 @@ icon: material/alert-decagram
"cipher_suites": [], "cipher_suites": [],
"certificate": "", "certificate": "",
"certificate_path": "", "certificate_path": "",
"fragment": false,
"fragment_fallback_delay": "",
"record_fragment": false,
"ech": { "ech": {
"enabled": false, "enabled": false,
"config": [], "config": [],
@ -313,6 +319,44 @@ The path to ECH configuration, in PEM format.
If empty, load from DNS will be attempted. 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 ### ACME Fields
#### domain #### domain

View File

@ -4,6 +4,9 @@ icon: material/alert-decagram
!!! quote "sing-box 1.12.0 中的更改" !!! 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.pq_signature_schemes_enabled](#pq_signature_schemes_enabled)
:material-delete-clock: [ech.dynamic_record_sizing_disabled](#dynamic_record_sizing_disabled) :material-delete-clock: [ech.dynamic_record_sizing_disabled](#dynamic_record_sizing_disabled)
@ -82,6 +85,9 @@ icon: material/alert-decagram
"cipher_suites": [], "cipher_suites": [],
"certificate": [], "certificate": [],
"certificate_path": "", "certificate_path": "",
"fragment": false,
"fragment_fallback_delay": "",
"record_fragment": false,
"ech": { "ech": {
"enabled": false, "enabled": false,
"pq_signature_schemes_enabled": false, "pq_signature_schemes_enabled": false,
@ -305,6 +311,41 @@ ECH PEM 配置路径
如果为 true则始终使用最大可能的 TLS 记录大小。 如果为 true则始终使用最大可能的 TLS 记录大小。
如果为 false则可能会调整 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 字段 ### ACME 字段
#### domain #### domain

View File

@ -37,19 +37,22 @@ func (o *InboundTLSOptionsContainer) ReplaceInboundTLSOptions(options *InboundTL
} }
type OutboundTLSOptions struct { type OutboundTLSOptions struct {
Enabled bool `json:"enabled,omitempty"` Enabled bool `json:"enabled,omitempty"`
DisableSNI bool `json:"disable_sni,omitempty"` DisableSNI bool `json:"disable_sni,omitempty"`
ServerName string `json:"server_name,omitempty"` ServerName string `json:"server_name,omitempty"`
Insecure bool `json:"insecure,omitempty"` Insecure bool `json:"insecure,omitempty"`
ALPN badoption.Listable[string] `json:"alpn,omitempty"` ALPN badoption.Listable[string] `json:"alpn,omitempty"`
MinVersion string `json:"min_version,omitempty"` MinVersion string `json:"min_version,omitempty"`
MaxVersion string `json:"max_version,omitempty"` MaxVersion string `json:"max_version,omitempty"`
CipherSuites badoption.Listable[string] `json:"cipher_suites,omitempty"` CipherSuites badoption.Listable[string] `json:"cipher_suites,omitempty"`
Certificate badoption.Listable[string] `json:"certificate,omitempty"` Certificate badoption.Listable[string] `json:"certificate,omitempty"`
CertificatePath string `json:"certificate_path,omitempty"` CertificatePath string `json:"certificate_path,omitempty"`
ECH *OutboundECHOptions `json:"ech,omitempty"` Fragment bool `json:"fragment,omitempty"`
UTLS *OutboundUTLSOptions `json:"utls,omitempty"` FragmentFallbackDelay badoption.Duration `json:"fragment_fallback_delay,omitempty"`
Reality *OutboundRealityOptions `json:"reality,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 { type OutboundTLSOptionsContainer struct {

View File

@ -90,14 +90,8 @@ func (m *ConnectionManager) NewConnection(ctx context.Context, this N.Dialer, co
m.logger.ErrorContext(ctx, err) m.logger.ErrorContext(ctx, err)
return return
} }
if metadata.TLSFragment { if metadata.TLSFragment || metadata.TLSRecordFragment {
fallbackDelay := metadata.TLSFragmentFallbackDelay remoteConn = tf.NewConn(remoteConn, ctx, metadata.TLSFragment, metadata.TLSRecordFragment, 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)
} }
m.access.Lock() m.access.Lock()
element := m.connections.PushBack(conn) element := m.connections.PushBack(conn)