feat: add TLS fragmentation

Introduce a feature to fragment TLS clientHello packets to circumvent TLS-based blockings (mostly seen in Iran's firewall)
This commit is contained in:
Kyōchikutō | キョウチクトウ 2023-11-24 17:25:22 +03:30
parent a4914fd214
commit 8bc40a6508
No known key found for this signature in database
GPG Key ID: A98846921CD44363
10 changed files with 331 additions and 63 deletions

View File

@ -98,11 +98,33 @@ func NewDefault(router adapter.Router, options option.DialerOptions) (*DefaultDi
} }
setMultiPathTCP(&dialer4) setMultiPathTCP(&dialer4)
} }
tcpDialer4, err := newTCPDialer(dialer4, options.TCPFastOpen) if options.TLSFragment.Enabled && options.TCPFastOpen {
return nil, E.New("TLS Fragmentation is not compatible with TCP Fast Open, set `tcp_fast_open` to `false` in your outbound if you intend to enable TLS fragmentation.")
}
var tlsFragment TLSFragment
if options.TLSFragment.Enabled {
tlsFragment.Enabled = true
sleep, err := option.ParseIntRange(options.TLSFragment.Sleep)
if err != nil {
return nil, E.Cause(err, "invalid TLS fragment sleep period supplied")
}
tlsFragment.SleepMin = sleep[0]
tlsFragment.SleepMax = sleep[1]
size, err := option.ParseIntRange(options.TLSFragment.Size)
if err != nil {
return nil, E.Cause(err, "invalid TLS fragment size supplied")
}
tlsFragment.SizeMin = size[0]
tlsFragment.SizeMax = size[1]
}
tcpDialer4, err := newTCPDialer(dialer4, options.TCPFastOpen, tlsFragment)
if err != nil { if err != nil {
return nil, err return nil, err
} }
tcpDialer6, err := newTCPDialer(dialer6, options.TCPFastOpen) tcpDialer6, err := newTCPDialer(dialer6, options.TCPFastOpen, tlsFragment)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -130,9 +152,9 @@ func (d *DefaultDialer) DialContext(ctx context.Context, network string, address
} }
} }
if !address.IsIPv6() { if !address.IsIPv6() {
return trackConn(DialSlowContext(&d.dialer4, ctx, network, address)) return trackConn(d.dialer4.DialContext(ctx, network, address))
} else { } else {
return trackConn(DialSlowContext(&d.dialer6, ctx, network, address)) return trackConn(d.dialer6.DialContext(ctx, network, address))
} }
} }

View File

@ -4,12 +4,10 @@ package dialer
import ( import (
"net" "net"
"github.com/sagernet/tfo-go"
) )
type tcpDialer = tfo.Dialer type tcpDialer = ExtendedTCPDialer
func newTCPDialer(dialer net.Dialer, tfoEnabled bool) (tcpDialer, error) { func newTCPDialer(dialer net.Dialer, tfoEnabled bool, tlsFragment TLSFragment) (tcpDialer, error) {
return tfo.Dialer{Dialer: dialer, DisableTFO: !tfoEnabled}, nil return tcpDialer{Dialer: dialer, DisableTFO: !tfoEnabled, TLSFragment: tlsFragment}, nil
} }

View File

@ -10,9 +10,12 @@ import (
type tcpDialer = net.Dialer type tcpDialer = net.Dialer
func newTCPDialer(dialer net.Dialer, tfoEnabled bool) (tcpDialer, error) { func newTCPDialer(dialer net.Dialer, tfoEnabled bool, tlsFragment TLSFragment) (tcpDialer, error) {
if tfoEnabled { if tfoEnabled {
return dialer, E.New("TCP Fast Open requires go1.20, please recompile your binary.") return dialer, E.New("TCP Fast Open requires go1.20, please recompile your binary.")
} }
if tlsFragment.Enabled {
return tcpDialer{Dialer: dialer, DisableTFO: true, TLSFragment: tlsFragment}, nil
}
return dialer, nil return dialer, nil
} }

View File

@ -0,0 +1,55 @@
//go:build go1.20
package dialer
import (
"context"
"net"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/tfo-go"
)
// Custom TCP dialer with extra features such as "TCP Fast Open" or "TLS Fragmentation"
type ExtendedTCPDialer struct {
net.Dialer
DisableTFO bool
TLSFragment TLSFragment
}
func (d *ExtendedTCPDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
if (d.DisableTFO && !d.TLSFragment.Enabled) || N.NetworkName(network) != N.NetworkTCP {
switch N.NetworkName(network) {
case N.NetworkTCP, N.NetworkUDP:
return d.Dialer.DialContext(ctx, network, destination.String())
default:
return d.Dialer.DialContext(ctx, network, destination.AddrString())
}
}
// Create a TLS-Fragmented dialer
if d.TLSFragment.Enabled {
fragmentConn := &fragmentConn{
dialer: &d.Dialer,
fragment: d.TLSFragment,
network: network,
destination: destination,
}
conn, err := d.Dialer.DialContext(ctx, network, destination.String())
if err != nil {
fragmentConn.err = err
return nil, err
}
fragmentConn.conn = conn
return fragmentConn, nil
}
// Create a TFO dialer
return &slowOpenConn{
dialer: &tfo.Dialer{Dialer: d.Dialer, DisableTFO: d.DisableTFO},
ctx: ctx,
network: network,
destination: destination,
create: make(chan struct{}),
},
nil
}

View File

@ -0,0 +1,36 @@
//go:build !go1.20
package dialer
import (
"context"
"net"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
)
func (d *ExtendedTCPDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
if !d.TLSFragment.Enabled || N.NetworkName(network) != N.NetworkTCP {
switch N.NetworkName(network) {
case N.NetworkTCP, N.NetworkUDP:
return d.Dialer.DialContext(ctx, network, destination.String())
default:
return d.Dialer.DialContext(ctx, network, destination.AddrString())
}
}
// Create a TLS-Fragmented dialer
fragmentConn := &fragmentConn{
dialer: &d.Dialer,
fragment: d.TLSFragment,
network: network,
destination: destination,
}
conn, err := d.Dialer.DialContext(ctx, network, destination.String())
if err != nil {
fragmentConn.err = err
return nil, err
}
fragmentConn.conn = conn
return fragmentConn, nil
}

154
common/dialer/fragment.go Normal file
View File

@ -0,0 +1,154 @@
package dialer
import (
"crypto/rand"
"encoding/binary"
"io"
"math/big"
"net"
"os"
"time"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/bufio"
M "github.com/sagernet/sing/common/metadata"
)
type TLSFragment struct {
Enabled bool
SizeMin uint64
SizeMax uint64
SleepMin uint64
SleepMax uint64
}
type fragmentConn struct {
dialer *net.Dialer
fragment TLSFragment
network string
destination M.Socksaddr
conn net.Conn
err error
}
func (c *fragmentConn) Read(b []byte) (n int, err error) {
if c.conn == nil {
return 0, c.err
}
return c.conn.Read(b)
}
func randBetween(left int64, right int64) int64 {
if left == right {
return left
}
bigInt, _ := rand.Int(rand.Reader, big.NewInt(right-left))
return left + bigInt.Int64()
}
func (c *fragmentConn) Write(b []byte) (n int, err error) {
if c.conn == nil {
return 0, c.err
}
// Check if payload is a valid TLS clientHello packet
if len(b) >= 5 && b[0] == 22 {
clientHelloLen := int(binary.BigEndian.Uint16(b[3:5]))
clientHelloData := b[5:]
for i := 0; i < clientHelloLen; {
fragmentEnd := i + int(randBetween(int64(c.fragment.SizeMin), int64(c.fragment.SizeMax)))
if fragmentEnd > clientHelloLen {
fragmentEnd = clientHelloLen
}
fragment := clientHelloData[i:fragmentEnd]
i = fragmentEnd
header := make([]byte, 5)
header[0] = b[0]
binary.BigEndian.PutUint16(header[1:], binary.BigEndian.Uint16(b[1:3]))
binary.BigEndian.PutUint16(header[3:], uint16(len(fragment)))
payload := append(header, fragment...)
_, err := c.conn.Write(payload)
if err != nil {
c.err = err
return 0, c.err
}
randomInterval := randBetween(int64(c.fragment.SleepMin), int64(c.fragment.SleepMax))
time.Sleep(time.Duration(randomInterval))
}
return len(b), nil
}
// Write directly if not a clientHello packet
return c.conn.Write(b)
}
func (c *fragmentConn) Close() error {
return common.Close(c.conn)
}
func (c *fragmentConn) LocalAddr() net.Addr {
if c.conn == nil {
return M.Socksaddr{}
}
return c.conn.LocalAddr()
}
func (c *fragmentConn) RemoteAddr() net.Addr {
if c.conn == nil {
return M.Socksaddr{}
}
return c.conn.RemoteAddr()
}
func (c *fragmentConn) SetDeadline(t time.Time) error {
if c.conn == nil {
return os.ErrInvalid
}
return c.conn.SetDeadline(t)
}
func (c *fragmentConn) SetReadDeadline(t time.Time) error {
if c.conn == nil {
return os.ErrInvalid
}
return c.conn.SetReadDeadline(t)
}
func (c *fragmentConn) SetWriteDeadline(t time.Time) error {
if c.conn == nil {
return os.ErrInvalid
}
return c.conn.SetWriteDeadline(t)
}
func (c *fragmentConn) Upstream() any {
return c.conn
}
func (c *fragmentConn) ReaderReplaceable() bool {
return c.conn != nil
}
func (c *fragmentConn) WriterReplaceable() bool {
return c.conn != nil
}
func (c *fragmentConn) LazyHeadroom() bool {
return c.conn == nil
}
func (c *fragmentConn) NeedHandshake() bool {
return c.conn == nil
}
func (c *fragmentConn) WriteTo(w io.Writer) (n int64, err error) {
if c.conn == nil {
return 0, c.err
}
return bufio.Copy(w, c.conn)
}

View File

@ -14,7 +14,6 @@ import (
"github.com/sagernet/sing/common/bufio" "github.com/sagernet/sing/common/bufio"
E "github.com/sagernet/sing/common/exceptions" E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata" M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/tfo-go" "github.com/sagernet/tfo-go"
) )
@ -29,24 +28,6 @@ type slowOpenConn struct {
err error err error
} }
func DialSlowContext(dialer *tcpDialer, ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
if dialer.DisableTFO || N.NetworkName(network) != N.NetworkTCP {
switch N.NetworkName(network) {
case N.NetworkTCP, N.NetworkUDP:
return dialer.Dialer.DialContext(ctx, network, destination.String())
default:
return dialer.Dialer.DialContext(ctx, network, destination.AddrString())
}
}
return &slowOpenConn{
dialer: dialer,
ctx: ctx,
network: network,
destination: destination,
create: make(chan struct{}),
}, nil
}
func (c *slowOpenConn) Read(b []byte) (n int, err error) { func (c *slowOpenConn) Read(b []byte) (n int, err error) {
if c.conn == nil { if c.conn == nil {
select { select {

View File

@ -1,20 +0,0 @@
//go:build !go1.20
package dialer
import (
"context"
"net"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
)
func DialSlowContext(dialer *tcpDialer, ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
switch N.NetworkName(network) {
case N.NetworkTCP, N.NetworkUDP:
return dialer.DialContext(ctx, network, destination.String())
default:
return dialer.DialContext(ctx, network, destination.AddrString())
}
}

38
option/fragment.go Normal file
View File

@ -0,0 +1,38 @@
package option
import (
"strconv"
"strings"
E "github.com/sagernet/sing/common/exceptions"
)
type TLSFragmentOptions struct {
Enabled bool `json:"enabled,omitempty"`
Size string `json:"size,omitempty"` // Fragment size in Bytes
Sleep string `json:"sleep,omitempty"` // Time to sleep between sending the fragments in milliseconds
}
func ParseIntRange(str string) ([]uint64, error) {
var err error
result := make([]uint64, 2)
splitString := strings.Split(str, "-")
if len(splitString) == 2 {
result[0], err = strconv.ParseUint(splitString[0], 10, 64)
if err != nil {
return nil, E.Cause(err, "Error parsing string to integer")
}
result[1], err = strconv.ParseUint(splitString[1], 10, 64)
if err != nil {
return nil, E.Cause(err, "Error parsing string to integer")
}
} else {
result[0], err = strconv.ParseUint(splitString[0], 10, 64)
if err != nil {
return nil, E.Cause(err, "Error parsing string to integer")
}
result[1] = result[0]
}
return result, err
}

View File

@ -130,20 +130,21 @@ func (h *Outbound) UnmarshalJSON(bytes []byte) error {
} }
type DialerOptions struct { type DialerOptions struct {
Detour string `json:"detour,omitempty"` Detour string `json:"detour,omitempty"`
BindInterface string `json:"bind_interface,omitempty"` BindInterface string `json:"bind_interface,omitempty"`
Inet4BindAddress *ListenAddress `json:"inet4_bind_address,omitempty"` Inet4BindAddress *ListenAddress `json:"inet4_bind_address,omitempty"`
Inet6BindAddress *ListenAddress `json:"inet6_bind_address,omitempty"` Inet6BindAddress *ListenAddress `json:"inet6_bind_address,omitempty"`
ProtectPath string `json:"protect_path,omitempty"` ProtectPath string `json:"protect_path,omitempty"`
RoutingMark int `json:"routing_mark,omitempty"` RoutingMark int `json:"routing_mark,omitempty"`
ReuseAddr bool `json:"reuse_addr,omitempty"` ReuseAddr bool `json:"reuse_addr,omitempty"`
ConnectTimeout Duration `json:"connect_timeout,omitempty"` ConnectTimeout Duration `json:"connect_timeout,omitempty"`
TCPFastOpen bool `json:"tcp_fast_open,omitempty"` TCPFastOpen bool `json:"tcp_fast_open,omitempty"`
TCPMultiPath bool `json:"tcp_multi_path,omitempty"` TCPMultiPath bool `json:"tcp_multi_path,omitempty"`
UDPFragment *bool `json:"udp_fragment,omitempty"` TLSFragment TLSFragmentOptions `json:"tls_fragment,omitempty"`
UDPFragmentDefault bool `json:"-"` UDPFragment *bool `json:"udp_fragment,omitempty"`
DomainStrategy DomainStrategy `json:"domain_strategy,omitempty"` UDPFragmentDefault bool `json:"-"`
FallbackDelay Duration `json:"fallback_delay,omitempty"` DomainStrategy DomainStrategy `json:"domain_strategy,omitempty"`
FallbackDelay Duration `json:"fallback_delay,omitempty"`
} }
type ServerOptions struct { type ServerOptions struct {