Add more spoof method

Signed-off-by: macronut <4027187+macronut@users.noreply.github.com>
This commit is contained in:
macronut 2026-04-29 19:24:49 +08:00 committed by 世界
parent ac900b1742
commit ef06e4c2c0
No known key found for this signature in database
GPG Key ID: CD109927C34A63C4
22 changed files with 360 additions and 1021 deletions

View File

@ -6,6 +6,7 @@ import (
"net/netip"
"time"
"github.com/sagernet/sing-box/common/tlsspoof"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
@ -75,6 +76,8 @@ type InboundContext struct {
TLSFragment bool
TLSFragmentFallbackDelay time.Duration
TLSRecordFragment bool
TLSSpoof string
TLSSpoofMethod tlsspoof.Method
NetworkStrategy *C.NetworkStrategy
NetworkType []C.InterfaceType

View File

@ -22,26 +22,20 @@ import (
var errMissingServerName = E.New("missing server_name or insecure=true")
func parseTLSSpoofOptions(serverName string, options option.OutboundTLSOptions) (string, tlsspoof.Method, error) {
if options.Spoof == "" {
if options.SpoofMethod != "" {
return "", 0, E.New("`spoof_method` requires `spoof`")
}
return "", 0, nil
spoof, method, err := tlsspoof.ParseOptions(options.Spoof, options.SpoofMethod)
if err != nil {
return "", 0, err
}
if !tlsspoof.PlatformSupported {
return "", 0, E.New("`spoof` is not supported on this platform")
if spoof == "" {
return "", 0, nil
}
if options.DisableSNI || serverName == "" || M.ParseAddr(serverName).IsValid() {
return "", 0, E.New("`spoof` requires TLS ClientHello with SNI")
}
if strings.EqualFold(options.Spoof, serverName) {
if strings.EqualFold(spoof, serverName) {
return "", 0, E.New("`spoof` must differ from `server_name`")
}
method, err := tlsspoof.ParseMethod(options.SpoofMethod)
if err != nil {
return "", 0, err
}
return options.Spoof, method, nil
return spoof, method, nil
}
func applyTLSSpoof(conn net.Conn, spoof string, method tlsspoof.Method) (net.Conn, error) {

View File

@ -1,154 +0,0 @@
package tls
import (
"context"
"crypto/tls"
"net"
"testing"
tf "github.com/sagernet/sing-box/common/tlsfragment"
"github.com/sagernet/sing-box/common/tlsspoof"
"github.com/sagernet/sing-box/option"
"github.com/stretchr/testify/require"
)
func TestParseTLSSpoofOptions_Disabled(t *testing.T) {
t.Parallel()
spoof, method, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{})
require.NoError(t, err)
require.Empty(t, spoof)
require.Equal(t, tlsspoof.MethodWrongSequence, method)
}
func TestParseTLSSpoofOptions_MethodWithoutSpoof(t *testing.T) {
t.Parallel()
_, _, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{
SpoofMethod: tlsspoof.MethodNameWrongChecksum,
})
require.Error(t, err)
}
func TestParseTLSSpoofOptions_IPLiteralRejected(t *testing.T) {
t.Parallel()
_, _, err := parseTLSSpoofOptions("1.2.3.4", option.OutboundTLSOptions{
Spoof: "example.com",
})
require.Error(t, err)
}
func TestParseTLSSpoofOptions_EmptyServerNameRejected(t *testing.T) {
t.Parallel()
_, _, err := parseTLSSpoofOptions("", option.OutboundTLSOptions{
Spoof: "example.com",
})
require.Error(t, err)
}
func TestParseTLSSpoofOptions_DisableSNIRejected(t *testing.T) {
t.Parallel()
_, _, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{
Spoof: "decoy.com",
DisableSNI: true,
})
require.Error(t, err)
}
// TestParseTLSSpoofOptions_RejectsSameSNI is the primary regression test for
// the "spoofed packet contains the original SNI" bug report: when a user
// configures spoof equal to server_name, the rewriter produces a byte-identical
// record, so the fake and real ClientHellos on the wire look the same. Reject
// at parse time.
func TestParseTLSSpoofOptions_RejectsSameSNI(t *testing.T) {
t.Parallel()
_, _, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{
Spoof: "example.com",
})
require.Error(t, err)
_, _, err = parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{
Spoof: "EXAMPLE.com",
})
require.Error(t, err, "comparison must be case-insensitive")
}
func TestParseTLSSpoofOptions_UnknownMethodRejected(t *testing.T) {
t.Parallel()
_, _, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{
Spoof: "decoy.com",
SpoofMethod: "nonsense",
})
require.Error(t, err)
}
func TestParseTLSSpoofOptions_DistinctSNIAccepted(t *testing.T) {
t.Parallel()
if !tlsspoof.PlatformSupported {
t.Skip("tlsspoof not supported on this platform")
}
spoof, method, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{
Spoof: "decoy.com",
SpoofMethod: tlsspoof.MethodNameWrongSequence,
})
require.NoError(t, err)
require.Equal(t, "decoy.com", spoof)
require.Equal(t, tlsspoof.MethodWrongSequence, method)
}
// The following tests guard the wrap gate in STDClientConfig.Client():
// tf.Conn must wrap the underlying connection whenever either `fragment` or
// `record_fragment` is set, so that TLS fragmentation coexists with features
// like tls_spoof that layer on top of tf.Conn.
func newSTDClientConfigForGateTest(fragment, recordFragment bool) *STDClientConfig {
return &STDClientConfig{
ctx: context.Background(),
config: &tls.Config{ServerName: "example.com", InsecureSkipVerify: true},
fragment: fragment,
recordFragment: recordFragment,
}
}
func TestSTDClient_Client_NoFragment_DoesNotWrap(t *testing.T) {
t.Parallel()
client, server := net.Pipe()
defer client.Close()
defer server.Close()
wrapped, err := newSTDClientConfigForGateTest(false, false).Client(client)
require.NoError(t, err)
_, isTF := wrapped.NetConn().(*tf.Conn)
require.False(t, isTF, "no fragment flags: must not wrap with tf.Conn")
}
func TestSTDClient_Client_FragmentOnly_Wraps(t *testing.T) {
t.Parallel()
client, server := net.Pipe()
defer client.Close()
defer server.Close()
wrapped, err := newSTDClientConfigForGateTest(true, false).Client(client)
require.NoError(t, err)
_, isTF := wrapped.NetConn().(*tf.Conn)
require.True(t, isTF, "fragment=true: must wrap with tf.Conn")
}
func TestSTDClient_Client_RecordFragmentOnly_Wraps(t *testing.T) {
t.Parallel()
client, server := net.Pipe()
defer client.Close()
defer server.Close()
wrapped, err := newSTDClientConfigForGateTest(false, true).Client(client)
require.NoError(t, err)
_, isTF := wrapped.NetConn().(*tf.Conn)
require.True(t, isTF, "record_fragment=true: must wrap with tf.Conn")
}
func TestSTDClient_Client_BothFragment_Wraps(t *testing.T) {
t.Parallel()
client, server := net.Pipe()
defer client.Close()
defer server.Close()
wrapped, err := newSTDClientConfigForGateTest(true, true).Client(client)
require.NoError(t, err)
_, isTF := wrapped.NetConn().(*tf.Conn)
require.True(t, isTF, "both fragment flags: must wrap with tf.Conn")
}

View File

@ -1,83 +0,0 @@
package tlsspoof
import (
"bytes"
"encoding/binary"
"strings"
"testing"
tf "github.com/sagernet/sing-box/common/tlsfragment"
"github.com/stretchr/testify/require"
)
// x25519MLKEM768 is the IANA code point for the post-quantum hybrid named
// group (0x11EC). The fake ClientHello must never carry it — its 1184-byte
// key share is the reason kernel-generated ClientHellos exceed one MSS, and
// the reason this builder has to force CurvePreferences.
const x25519MLKEM768 uint16 = 0x11EC
func TestBuildFakeClientHello_ParsesWithSNI(t *testing.T) {
t.Parallel()
record, err := buildFakeClientHello("example.com")
require.NoError(t, err)
serverName := tf.IndexTLSServerName(record)
require.NotNil(t, serverName, "output must parse as a ClientHello")
require.Equal(t, "example.com", serverName.ServerName)
recordLen := binary.BigEndian.Uint16(record[3:5])
require.Equal(t, len(record)-5, int(recordLen),
"record length header must match on-wire record size")
handshakeLen := int(record[6])<<16 | int(record[7])<<8 | int(record[8])
require.Equal(t, len(record)-5-4, handshakeLen,
"handshake length header must match handshake body size")
}
// TestBuildFakeClientHello_FitsOneSegment is the regression guard for the
// whole point of the rewrite: the fake must never need fragmenting on a
// standard 1500-byte path MTU. 1200 leaves ~260 bytes for IP+TCP headers and
// a generous safety margin — the X25519MLKEM768 ClientHello this replaces
// hit ~1400+.
func TestBuildFakeClientHello_FitsOneSegment(t *testing.T) {
t.Parallel()
for _, sni := range []string{"a.io", "example.com", strings.Repeat("a", 253)} {
record, err := buildFakeClientHello(sni)
require.NoError(t, err, "sni=%q", sni)
require.Less(t, len(record), 1200, "sni=%q built %d bytes", sni, len(record))
}
}
// TestBuildFakeClientHello_NoPostQuantumKeyShare catches regressions that
// would accidentally pull an X25519MLKEM768 key share (the reason the prior
// implementation had to fragment) back into the fake — e.g. if CurvePreferences
// stopped being respected by a future Go version.
func TestBuildFakeClientHello_NoPostQuantumKeyShare(t *testing.T) {
t.Parallel()
record, err := buildFakeClientHello("example.com")
require.NoError(t, err)
var needle [2]byte
binary.BigEndian.PutUint16(needle[:], x25519MLKEM768)
require.False(t, bytes.Contains(record, needle[:]),
"output must not contain the X25519MLKEM768 code point (0x%04x)", x25519MLKEM768)
}
// TestBuildFakeClientHello_RandomizesPerCall ensures crypto/tls generates a
// fresh random + session_id + key_share on every call, as required to avoid
// trivial fingerprinting of the spoof.
func TestBuildFakeClientHello_RandomizesPerCall(t *testing.T) {
t.Parallel()
first, err := buildFakeClientHello("example.com")
require.NoError(t, err)
second, err := buildFakeClientHello("example.com")
require.NoError(t, err)
require.NotEqual(t, first, second,
"repeated calls must produce distinct bytes (random/session_id/key_share must vary)")
}
func TestBuildFakeClientHello_RejectsEmpty(t *testing.T) {
t.Parallel()
_, err := buildFakeClientHello("")
require.Error(t, err)
}

View File

@ -1,369 +0,0 @@
package tlsspoof
import (
"bytes"
"context"
"encoding/binary"
"encoding/hex"
"io"
"net"
"testing"
"time"
tf "github.com/sagernet/sing-box/common/tlsfragment"
"github.com/stretchr/testify/require"
)
// realClientHello is a captured Chrome ClientHello for github.com. Tests that
// stack tlsspoof.Conn on top of tf.Conn still need a parseable payload to
// exercise the fragment transform.
const realClientHello = "16030105f8010005f403036e35de7389a679c54029cf452611f2211c70d9ac3897271de589ab6155f8e4ab20637d225f1ef969ad87ed78bfb9d171300bcb1703b6f314ccefb964f79b7d0961002a0a0a130213031301c02cc02bcca9c030c02fcca8c00ac009c014c013009d009c0035002fc008c012000a01000581baba00000000000f000d00000a6769746875622e636f6d00170000ff01000100000a000e000c3a3a11ec001d001700180019000b000201000010000e000c02683208687474702f312e31000500050100000000000d00160014040308040401050308050805050108060601020100120000003304ef04ed3a3a00010011ec04c0aeb2250c092a3463161cccb29d9183331a424964248579507ed23a180b0ceab2a5f5d9ce41547e497a89055471ea572867ba3a1fc3c9e45025274a20f60c6b60e62476b6afed0403af59ab83660ef4112ae20386a602010d0a5d454c0ed34c84ed4423e750213e6a2baab1bf9c4367a6007ab40a33d95220c2dcaa44f257024a5626b545db0510f4311b1a60714154909c6a61fdfca011fb2626d657aeb6070bf078508babe3b584555013e34acc56198ed4663742b3155a664a9901794c4586820a7dc162c01827291f3792e1237f801a8d1ef096013c181c4a58d2f6859ba75022d18cc4418bd4f351d5c18f83a58857d05af860c4b9ac018a5b63f17184e591532c6bc2cf2215d4a282c8a8a4f6f7aee110422c8bc9ebd3b1d609c568523aaae555db320e6c269473d87af38c256cbb9febc20aea6380c32a8916f7a373c8b1e37554e3260bf6621f6b804ee80b3c516b1d01985bf4c603b6daa9a5991de6a7a29f3a7122b8afb843a7660110fce62b43c615f5bcc2db688ba012649c0952b0a2c031e732d2b454c6b2968683cb8d244be2c9a7fa163222979eaf92722b92b862d81a3d94450c2b60c318421ebb4307c42d1f0473592a5c30e42039cc68cda9721e61aa63f49def17c15221680ed444896340133bbee67556f56b9f9d78a4df715f926a12add0cc9c862e46ea8b7316ae468282c18601b2771c9c9322f982228cf93effaacd3f80cbd12bce5fc36f56e2a3caf91e578a5fae00c9b23a8ed1a66764f4433c3628a70b8f0a6196adc60a4cb4226f07ba4c6b363fe9065563bfc1347452946386bab488686e837ab979c64f9047417fca635fe1bb4f074f256cc8af837c7b455e280426547755af90a61640169ef180aea3a77e662bb6dac1b6c3696027129b1a5edf495314e9c7f4b6110e16378ec893fa24642330a40aba1a85326101acb97c620fd8d71389e69eaed7bdb01bbe1fd428d66191150c7b2cd1ad4257391676a82ba8ce07fb2667c3b289f159003a7c7bc31d361b7b7f49a802961739d950dfcc0fa1c7abce5abdd2245101da391151490862028110465950b9e9c03d08a90998ab83267838d2e74a0593bc81f74cdf734519a05b351c0e5488c68dd810e6e9142ccc1e2f4a7f464297eb340e27acc6b9d64e12e38cce8492b3d939140b5a9e149a75597f10a23874c84323a07cdd657274378f887c85c4259b9c04cd33ba58ed630ef2a744f8e19dd34843dff331d2a6be7e2332c599289cd248a611c73d7481cd4a9bd43449a3836f14b2af18a1739e17999e4c67e85cc5bcecabb14185e5bcaff3c96098f03dc5aba819f29587758f49f940585354a2a780830528d68ccd166920dadcaa25cab5fc1907272a826aba3f08bc6b88757776812ecb6c7cec69a223ec0a13a7b62a2349a0f63ed7a27a3b15ba21d71fe6864ec6e089ae17cadd433fa3138f7ee24353c11365818f8fc34f43a05542d18efaac24bfccc1f748a0cc1a67ad379468b76fd34973dba785f5c91d618333cd810fe0700d1bbc8422029782628070a624c52c5309a4a64d625b11f8033ab28df34a1add297517fcc06b92b6817b3c5144438cf260867c57bde68c8c4b82e6a135ef676a52fbae5708002a404e6189a60e2836de565ad1b29e3819e5ed49f6810bcb28e1bd6de57306f94b79d9dae1cc4624d2a068499beef81cd5fe4b76dcbfff2a2008001d002001976128c6d5a934533f28b9914d2480aab2a8c1ab03d212529ce8b27640a716002d00020101002b000706caca03040303001b00030200015a5a000100"
func decodeClientHello(t *testing.T) []byte {
t.Helper()
payload, err := hex.DecodeString(realClientHello)
require.NoError(t, err)
return payload
}
type fakeSpoofer struct {
injected [][]byte
err error
closeErr error
}
func (f *fakeSpoofer) Inject(payload []byte) error {
if f.err != nil {
return f.err
}
f.injected = append(f.injected, append([]byte(nil), payload...))
return nil
}
func (f *fakeSpoofer) Close() error {
return f.closeErr
}
func readAll(t *testing.T, conn net.Conn) []byte {
t.Helper()
data, err := io.ReadAll(conn)
require.NoError(t, err)
return data
}
func TestConn_Write_InjectsThenForwards(t *testing.T) {
t.Parallel()
payload := decodeClientHello(t)
client, server := net.Pipe()
spoofer := &fakeSpoofer{}
wrapped, err := newConn(client, spoofer, "letsencrypt.org")
require.NoError(t, err)
serverRead := make(chan []byte, 1)
go func() {
serverRead <- readAll(t, server)
}()
n, err := wrapped.Write(payload)
require.NoError(t, err)
require.Equal(t, len(payload), n)
require.NoError(t, wrapped.Close())
forwarded := <-serverRead
require.Equal(t, payload, forwarded, "underlying conn must receive the real ClientHello unchanged")
require.Len(t, spoofer.injected, 1)
injected := spoofer.injected[0]
serverName := tf.IndexTLSServerName(injected)
require.NotNil(t, serverName, "injected payload must parse as ClientHello")
require.Equal(t, "letsencrypt.org", serverName.ServerName)
}
func TestConn_Write_SecondWriteDoesNotInject(t *testing.T) {
t.Parallel()
payload := decodeClientHello(t)
client, server := net.Pipe()
spoofer := &fakeSpoofer{}
wrapped, err := newConn(client, spoofer, "letsencrypt.org")
require.NoError(t, err)
serverRead := make(chan []byte, 1)
go func() {
serverRead <- readAll(t, server)
}()
_, err = wrapped.Write(payload)
require.NoError(t, err)
_, err = wrapped.Write([]byte("second"))
require.NoError(t, err)
require.NoError(t, wrapped.Close())
forwarded := <-serverRead
require.Equal(t, append(append([]byte(nil), payload...), []byte("second")...), forwarded)
require.Len(t, spoofer.injected, 1)
}
// TestConn_Write_SurfacesCloseError guards against the defer pattern silently
// dropping the spoofer's Close() error on the success path.
func TestConn_Write_SurfacesCloseError(t *testing.T) {
t.Parallel()
client, server := net.Pipe()
defer client.Close()
defer server.Close()
spoofer := &fakeSpoofer{closeErr: errSpoofClose}
wrapped, err := newConn(client, spoofer, "letsencrypt.org")
require.NoError(t, err)
go func() { _, _ = io.ReadAll(server) }()
_, err = wrapped.Write([]byte("trigger inject"))
require.ErrorIs(t, err, errSpoofClose,
"Close() error must be wrapped into Write's return")
}
func TestConn_NewConn_RejectsEmptySNI(t *testing.T) {
t.Parallel()
client, server := net.Pipe()
defer client.Close()
defer server.Close()
_, err := newConn(client, &fakeSpoofer{}, "")
require.Error(t, err, "empty SNI must fail at construction")
}
var errSpoofClose = errTest("spoof-close-failed")
type errTest string
func (e errTest) Error() string { return string(e) }
// recordingConn intercepts each Write call so tests can assert how many
// downstream writes occurred and in what order with respect to spoof
// injection. It does not implement WithUpstream, so tf.Conn's
// N.UnwrapReader(conn).(*net.TCPConn) returns nil and fragment-mode falls
// back to its plain Write + time.Sleep path — which is what we want to
// exercise over a net.Pipe.
type recordingConn struct {
net.Conn
writes [][]byte
timeline *[]string
}
func (c *recordingConn) Write(p []byte) (int, error) {
c.writes = append(c.writes, append([]byte(nil), p...))
if c.timeline != nil {
*c.timeline = append(*c.timeline, "write")
}
return c.Conn.Write(p)
}
type tlsRecord struct {
contentType byte
payload []byte
}
func parseTLSRecords(t *testing.T, data []byte) []tlsRecord {
t.Helper()
var records []tlsRecord
for len(data) > 0 {
require.GreaterOrEqual(t, len(data), 5, "record header incomplete")
recordLen := int(binary.BigEndian.Uint16(data[3:5]))
require.GreaterOrEqual(t, len(data), 5+recordLen, "record payload truncated")
records = append(records, tlsRecord{
contentType: data[0],
payload: append([]byte(nil), data[5:5+recordLen]...),
})
data = data[5+recordLen:]
}
return records
}
// TestConn_StackedWithRecordFragment mirrors the wrapping order that
// STDClientConfig.Client() produces when record_fragment is enabled:
// tls.Client → tlsspoof.Conn → tf.Conn → raw conn.
// Asserts the decoy is injected and the real handshake arrives split into
// multiple TLS records whose payloads reassemble to the original.
func TestConn_StackedWithRecordFragment(t *testing.T) {
t.Parallel()
payload := decodeClientHello(t)
client, server := net.Pipe()
defer server.Close()
fragConn := tf.NewConn(client, context.Background(), false, true, time.Millisecond)
spoofer := &fakeSpoofer{}
wrapped, err := newConn(fragConn, spoofer, "letsencrypt.org")
require.NoError(t, err)
serverRead := make(chan []byte, 1)
go func() { serverRead <- readAll(t, server) }()
_, err = wrapped.Write(payload)
require.NoError(t, err)
require.NoError(t, wrapped.Close())
forwarded := <-serverRead
require.Len(t, spoofer.injected, 1, "spoof must inject exactly once")
injected := tf.IndexTLSServerName(spoofer.injected[0])
require.NotNil(t, injected, "injected payload must parse as ClientHello")
require.Equal(t, "letsencrypt.org", injected.ServerName)
records := parseTLSRecords(t, forwarded)
require.Greater(t, len(records), 1, "record_fragment must produce multiple records")
var reassembled []byte
for _, r := range records {
require.Equal(t, byte(0x16), r.contentType, "all records must be handshake")
reassembled = append(reassembled, r.payload...)
}
require.Equal(t, payload[5:], reassembled, "record payloads must reassemble to original handshake")
}
// TestConn_StackedWithPacketFragment is the primary regression test for the
// fragment-only gate fix in STDClientConfig.Client(). It verifies that
// packet-level fragmentation combined with spoof produces:
// - one spoof injection carrying the decoy SNI,
// - multiple separate writes to the underlying conn,
// - an unmodified byte stream when those writes are concatenated
// (no extra record framing).
func TestConn_StackedWithPacketFragment(t *testing.T) {
t.Parallel()
payload := decodeClientHello(t)
client, server := net.Pipe()
defer server.Close()
rc := &recordingConn{Conn: client}
fragConn := tf.NewConn(rc, context.Background(), true, false, time.Millisecond)
spoofer := &fakeSpoofer{}
wrapped, err := newConn(fragConn, spoofer, "letsencrypt.org")
require.NoError(t, err)
serverRead := make(chan []byte, 1)
go func() { serverRead <- readAll(t, server) }()
_, err = wrapped.Write(payload)
require.NoError(t, err)
require.NoError(t, wrapped.Close())
forwarded := <-serverRead
require.Len(t, spoofer.injected, 1, "spoof must inject exactly once")
injected := tf.IndexTLSServerName(spoofer.injected[0])
require.NotNil(t, injected)
require.Equal(t, "letsencrypt.org", injected.ServerName)
require.Greater(t, len(rc.writes), 1, "fragment must split the ClientHello into multiple writes")
require.Equal(t, payload, bytes.Join(rc.writes, nil),
"concatenated writes must equal original bytes (no extra framing)")
require.Equal(t, payload, forwarded)
}
// TestConn_StackedWithBothFragment exercises the combination that produces
// the strongest obfuscation: each chunk becomes its own TLS record and its
// own TCP write.
func TestConn_StackedWithBothFragment(t *testing.T) {
t.Parallel()
payload := decodeClientHello(t)
client, server := net.Pipe()
defer server.Close()
rc := &recordingConn{Conn: client}
fragConn := tf.NewConn(rc, context.Background(), true, true, time.Millisecond)
spoofer := &fakeSpoofer{}
wrapped, err := newConn(fragConn, spoofer, "letsencrypt.org")
require.NoError(t, err)
serverRead := make(chan []byte, 1)
go func() { serverRead <- readAll(t, server) }()
_, err = wrapped.Write(payload)
require.NoError(t, err)
require.NoError(t, wrapped.Close())
forwarded := <-serverRead
require.Len(t, spoofer.injected, 1)
injected := tf.IndexTLSServerName(spoofer.injected[0])
require.NotNil(t, injected)
require.Equal(t, "letsencrypt.org", injected.ServerName)
require.Greater(t, len(rc.writes), 1, "split-packet must produce multiple writes")
records := parseTLSRecords(t, forwarded)
require.Greater(t, len(records), 1, "split-record must produce multiple records")
var reassembled []byte
for _, r := range records {
require.Equal(t, byte(0x16), r.contentType)
reassembled = append(reassembled, r.payload...)
}
require.Equal(t, payload[5:], reassembled,
"record payloads must reassemble to the original handshake")
}
// trackingSpoofer adds the spoof injection to a shared event timeline so
// TestConn_StackedInjectionOrder can prove the decoy precedes the first
// downstream write.
type trackingSpoofer struct {
injected [][]byte
timeline *[]string
}
func (s *trackingSpoofer) Inject(payload []byte) error {
s.injected = append(s.injected, append([]byte(nil), payload...))
*s.timeline = append(*s.timeline, "inject")
return nil
}
func (s *trackingSpoofer) Close() error { return nil }
// TestConn_StackedInjectionOrder asserts the documented wire order: the
// decoy injection happens before any write reaches the underlying conn.
func TestConn_StackedInjectionOrder(t *testing.T) {
t.Parallel()
payload := decodeClientHello(t)
client, server := net.Pipe()
defer server.Close()
var timeline []string
rc := &recordingConn{Conn: client, timeline: &timeline}
fragConn := tf.NewConn(rc, context.Background(), true, true, time.Millisecond)
spoofer := &trackingSpoofer{timeline: &timeline}
wrapped, err := newConn(fragConn, spoofer, "letsencrypt.org")
require.NoError(t, err)
serverRead := make(chan []byte, 1)
go func() { serverRead <- readAll(t, server) }()
_, err = wrapped.Write(payload)
require.NoError(t, err)
require.NoError(t, wrapped.Close())
<-serverRead
require.NotEmpty(t, timeline)
require.Equal(t, "inject", timeline[0], "decoy must be injected before any downstream write")
require.Contains(t, timeline[1:], "write", "at least one downstream write must follow the inject")
}
func TestParseMethod(t *testing.T) {
t.Parallel()
cases := map[string]struct {
want Method
ok bool
}{
"": {MethodWrongSequence, true},
"wrong-sequence": {MethodWrongSequence, true},
"wrong-checksum": {MethodWrongChecksum, true},
"nonsense": {0, false},
}
for input, expected := range cases {
m, err := ParseMethod(input)
if !expected.ok {
require.Error(t, err, "input=%q", input)
continue
}
require.NoError(t, err, "input=%q", input)
require.Equal(t, expected.want, m, "input=%q", input)
}
}

View File

@ -6,74 +6,53 @@ import (
"encoding/hex"
"io"
"net"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestIntegrationSpoofer_WrongChecksum(t *testing.T) {
func TestIntegrationSpoofer(t *testing.T) {
requireRoot(t)
client, serverPort := dialLocalEchoServer(t)
spoofer, err := newRawSpoofer(client, MethodWrongChecksum)
require.NoError(t, err)
defer spoofer.Close()
methods := []struct {
name string
method Method
}{
{"WrongChecksum", MethodWrongChecksum},
{"WrongSequence", MethodWrongSequence},
{"WrongAcknowledgment", MethodWrongAcknowledgment},
{"WrongMD5Sig", MethodWrongMD5Sig},
{"WrongTimestamp", MethodWrongTimestamp},
}
families := []struct {
name string
dial func(*testing.T) (net.Conn, uint16)
}{
{"IPv4", dialLocalEchoServer},
{"IPv6", dialLocalEchoServerIPv6},
}
for _, family := range families {
for _, tc := range methods {
t.Run(family.name+"/"+tc.name, func(t *testing.T) {
if tc.method == MethodWrongTimestamp && runtime.GOOS == "darwin" {
t.Skip("wrong-timestamp is not supported on macOS")
}
client, serverPort := family.dial(t)
spoofer, err := newRawSpoofer(client, tc.method)
require.NoError(t, err)
defer spoofer.Close()
fake, err := buildFakeClientHello("letsencrypt.org")
require.NoError(t, err)
fake, err := buildFakeClientHello("letsencrypt.org")
require.NoError(t, err)
captured := tcpdumpObserver(t, loopbackInterface, serverPort, "letsencrypt.org", func() {
require.NoError(t, spoofer.Inject(fake))
}, 3*time.Second)
require.True(t, captured, "injected fake ClientHello must be observable on loopback")
}
func TestIntegrationSpoofer_WrongSequence(t *testing.T) {
requireRoot(t)
client, serverPort := dialLocalEchoServer(t)
spoofer, err := newRawSpoofer(client, MethodWrongSequence)
require.NoError(t, err)
defer spoofer.Close()
fake, err := buildFakeClientHello("letsencrypt.org")
require.NoError(t, err)
captured := tcpdumpObserver(t, loopbackInterface, serverPort, "letsencrypt.org", func() {
require.NoError(t, spoofer.Inject(fake))
}, 3*time.Second)
require.True(t, captured, "injected fake ClientHello must be observable on loopback")
}
func TestIntegrationSpoofer_IPv6_WrongChecksum(t *testing.T) {
requireRoot(t)
client, serverPort := dialLocalEchoServerIPv6(t)
spoofer, err := newRawSpoofer(client, MethodWrongChecksum)
require.NoError(t, err)
defer spoofer.Close()
fake, err := buildFakeClientHello("letsencrypt.org")
require.NoError(t, err)
captured := tcpdumpObserver(t, loopbackInterface, serverPort, "letsencrypt.org", func() {
require.NoError(t, spoofer.Inject(fake))
}, 3*time.Second)
require.True(t, captured, "injected fake ClientHello must be observable on loopback")
}
func TestIntegrationSpoofer_IPv6_WrongSequence(t *testing.T) {
requireRoot(t)
client, serverPort := dialLocalEchoServerIPv6(t)
spoofer, err := newRawSpoofer(client, MethodWrongSequence)
require.NoError(t, err)
defer spoofer.Close()
fake, err := buildFakeClientHello("letsencrypt.org")
require.NoError(t, err)
captured := tcpdumpObserver(t, loopbackInterface, serverPort, "letsencrypt.org", func() {
require.NoError(t, spoofer.Inject(fake))
}, 3*time.Second)
require.True(t, captured, "injected fake ClientHello must be observable on loopback")
captured := tcpdumpObserver(t, loopbackInterface, serverPort, "letsencrypt.org", func() {
require.NoError(t, spoofer.Inject(fake))
}, 3*time.Second)
require.True(t, captured, "injected fake ClientHello must be observable on loopback")
})
}
}
}
// Loopback bypasses TCP checksum validation, so wrong-sequence is used instead.

View File

@ -1,6 +1,7 @@
package tlsspoof
import (
"encoding/binary"
"net/netip"
"github.com/sagernet/sing-tun/gtcpip/checksum"
@ -12,15 +13,24 @@ const (
defaultTTL uint8 = 64
defaultWindowSize uint16 = 0xFFFF
tcpHeaderLen = header.TCPMinimumSize
tcpOptionMD5Signature = 19
tcpOptionMD5SignatureLength = 18
tcpTimestampBackdate = 3600000
)
type spoofPacketInfo struct {
seqNum uint32
ackNum uint32
corrupt bool
options []byte
}
func buildTCPSegment(
src netip.AddrPort,
dst netip.AddrPort,
seqNum uint32,
ackNum uint32,
packetInfo spoofPacketInfo,
payload []byte,
corruptChecksum bool,
) []byte {
if src.Addr().Is4() != dst.Addr().Is4() {
panic("tlsspoof: mixed IPv4/IPv6 address family")
@ -29,9 +39,10 @@ func buildTCPSegment(
frame []byte
ipHeaderLen int
)
ipPayloadLen := tcpHeaderLen + len(packetInfo.options) + len(payload)
if src.Addr().Is4() {
ipHeaderLen = header.IPv4MinimumSize
frame = make([]byte, ipHeaderLen+tcpHeaderLen+len(payload))
frame = make([]byte, ipHeaderLen+ipPayloadLen)
ip := header.IPv4(frame[:ipHeaderLen])
ip.Encode(&header.IPv4Fields{
TotalLength: uint16(len(frame)),
@ -44,68 +55,128 @@ func buildTCPSegment(
ip.SetChecksum(^ip.CalculateChecksum())
} else {
ipHeaderLen = header.IPv6MinimumSize
frame = make([]byte, ipHeaderLen+tcpHeaderLen+len(payload))
frame = make([]byte, ipHeaderLen+ipPayloadLen)
ip := header.IPv6(frame[:ipHeaderLen])
ip.Encode(&header.IPv6Fields{
PayloadLength: uint16(tcpHeaderLen + len(payload)),
PayloadLength: uint16(ipPayloadLen),
TransportProtocol: header.TCPProtocolNumber,
HopLimit: defaultTTL,
SrcAddr: src.Addr(),
DstAddr: dst.Addr(),
})
}
encodeTCP(frame, ipHeaderLen, src, dst, seqNum, ackNum, payload, corruptChecksum)
encodeTCP(frame, ipHeaderLen, src, dst, packetInfo, payload)
return frame
}
func encodeTCP(frame []byte, ipHeaderLen int, src, dst netip.AddrPort, seqNum, ackNum uint32, payload []byte, corruptChecksum bool) {
func encodeTCP(frame []byte, ipHeaderLen int, src, dst netip.AddrPort, packetInfo spoofPacketInfo, payload []byte) {
tcp := header.TCP(frame[ipHeaderLen:])
copy(frame[ipHeaderLen+tcpHeaderLen:], payload)
copy(frame[ipHeaderLen+tcpHeaderLen:], packetInfo.options)
optionsLen := len(packetInfo.options)
copy(frame[ipHeaderLen+tcpHeaderLen+optionsLen:], payload)
tcp.Encode(&header.TCPFields{
SrcPort: src.Port(),
DstPort: dst.Port(),
SeqNum: seqNum,
AckNum: ackNum,
DataOffset: tcpHeaderLen,
SeqNum: packetInfo.seqNum,
AckNum: packetInfo.ackNum,
DataOffset: uint8(tcpHeaderLen + optionsLen),
Flags: header.TCPFlagAck | header.TCPFlagPsh,
WindowSize: defaultWindowSize,
})
applyTCPChecksum(tcp, src.Addr(), dst.Addr(), payload, corruptChecksum)
applyTCPChecksum(tcp, src.Addr(), dst.Addr(), payload, packetInfo.corrupt)
}
func buildSpoofFrame(method Method, src, dst netip.AddrPort, sendNext, receiveNext uint32, payload []byte) ([]byte, error) {
sequence, corrupt, err := resolveSpoofSequence(method, sendNext, payload)
func buildSpoofFrame(method Method, src, dst netip.AddrPort, sendNext, receiveNext, timestamp uint32, tcpOptions, payload []byte) ([]byte, error) {
packetInfo, err := resolveSpoofPacketInfo(method, sendNext, receiveNext, timestamp, tcpOptions, payload)
if err != nil {
return nil, err
}
return buildTCPSegment(src, dst, sequence, receiveNext, payload, corrupt), nil
return buildTCPSegment(src, dst, packetInfo, payload), nil
}
// buildSpoofTCPSegment returns a TCP segment without an IP header, for
// platforms where the kernel synthesises the IP header (darwin IPv6).
func buildSpoofTCPSegment(method Method, src, dst netip.AddrPort, sendNext, receiveNext uint32, payload []byte) ([]byte, error) {
sequence, corrupt, err := resolveSpoofSequence(method, sendNext, payload)
func buildSpoofTCPSegment(method Method, src, dst netip.AddrPort, sendNext, receiveNext, timestamp uint32, payload []byte) ([]byte, error) {
packetInfo, err := resolveSpoofPacketInfo(method, sendNext, receiveNext, timestamp, nil, payload)
if err != nil {
return nil, err
}
segment := make([]byte, tcpHeaderLen+len(payload))
encodeTCP(segment, 0, src, dst, sequence, receiveNext, payload, corrupt)
segment := make([]byte, tcpHeaderLen+len(packetInfo.options)+len(payload))
encodeTCP(segment, 0, src, dst, packetInfo, payload)
return segment, nil
}
func resolveSpoofSequence(method Method, sendNext uint32, payload []byte) (uint32, bool, error) {
func resolveSpoofPacketInfo(method Method, sendNext, receiveNext, timestamp uint32, tcpOptions, payload []byte) (spoofPacketInfo, error) {
packetInfo := spoofPacketInfo{seqNum: sendNext, ackNum: receiveNext}
switch method {
case MethodWrongSequence:
return sendNext - uint32(len(payload)), false, nil
packetInfo.seqNum = sendNext - uint32(len(payload))
case MethodWrongChecksum:
return sendNext, true, nil
packetInfo.corrupt = true
case MethodWrongAcknowledgment:
packetInfo.ackNum = receiveNext - uint32(defaultWindowSize/2)
case MethodWrongMD5Sig:
packetInfo.options = buildMD5SignatureOptions()
case MethodWrongTimestamp:
packetInfo.options = buildWrongTimestampOptions(timestamp, tcpOptions)
default:
return 0, false, E.New("tls_spoof: unknown method ", method)
return packetInfo, E.New("tls_spoof: unknown method ", method)
}
return packetInfo, nil
}
func buildMD5SignatureOptions() []byte {
options := make([]byte, tcpOptionMD5SignatureLength+2)
options[0] = tcpOptionMD5Signature
options[1] = tcpOptionMD5SignatureLength
return options
}
func buildWrongTimestampOptions(timestamp uint32, tcpOptions []byte) []byte {
spoofedTimestamp := timestamp
if spoofedTimestamp > tcpTimestampBackdate {
spoofedTimestamp -= tcpTimestampBackdate
} else {
spoofedTimestamp = 0
}
if rewriteTCPOptionTimestamp(tcpOptions, spoofedTimestamp) {
return tcpOptions
}
options := make([]byte, header.TCPOptionTSLength+2)
header.EncodeTSOption(spoofedTimestamp, 0, options)
return options
}
// rewriteTCPOptionTimestamp finds the TS option in tcpOptions and writes
// timestamp into its TSVal field in place. The caller must own tcpOptions
// (parseTCPPacket already returns a private copy on Windows).
func rewriteTCPOptionTimestamp(tcpOptions []byte, timestamp uint32) bool {
for i := 0; i < len(tcpOptions); {
switch tcpOptions[i] {
case header.TCPOptionEOL:
return false
case header.TCPOptionNOP:
i++
continue
}
if i+1 >= len(tcpOptions) {
return false
}
optionLen := int(tcpOptions[i+1])
if optionLen < 2 || i+optionLen > len(tcpOptions) {
return false
}
if tcpOptions[i] == header.TCPOptionTS && optionLen == header.TCPOptionTSLength {
binary.BigEndian.PutUint32(tcpOptions[i+2:], timestamp)
return true
}
i += optionLen
}
return false
}
func applyTCPChecksum(tcp header.TCP, srcAddr, dstAddr netip.Addr, payload []byte, corrupt bool) {
tcpLen := tcpHeaderLen + len(payload)
tcpLen := int(tcp.DataOffset()) + len(payload)
pseudo := header.PseudoHeaderChecksum(header.TCPProtocolNumber, srcAddr.AsSlice(), dstAddr.AsSlice(), uint16(tcpLen))
payloadChecksum := checksum.Checksum(payload, 0)
tcpChecksum := ^tcp.CalculateChecksum(checksum.Combine(pseudo, payloadChecksum))

View File

@ -1,136 +0,0 @@
package tlsspoof
import (
"net/netip"
"testing"
"github.com/sagernet/sing-tun/gtcpip"
"github.com/sagernet/sing-tun/gtcpip/checksum"
"github.com/sagernet/sing-tun/gtcpip/header"
"github.com/stretchr/testify/require"
)
func TestBuildTCPSegment_IPv4_ValidChecksum(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("10.0.0.1:54321")
dst := netip.MustParseAddrPort("1.2.3.4:443")
payload := []byte("fake-client-hello")
frame := buildTCPSegment(src, dst, 100_000, 200_000, payload, false)
ip := header.IPv4(frame[:header.IPv4MinimumSize])
require.True(t, ip.IsChecksumValid())
tcp := header.TCP(frame[header.IPv4MinimumSize:])
payloadChecksum := checksum.Checksum(payload, 0)
require.True(t, tcp.IsChecksumValid(
tcpip.AddrFrom4(src.Addr().As4()),
tcpip.AddrFrom4(dst.Addr().As4()),
payloadChecksum,
uint16(len(payload)),
))
}
func TestBuildTCPSegment_IPv4_CorruptChecksum(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("10.0.0.1:54321")
dst := netip.MustParseAddrPort("1.2.3.4:443")
payload := []byte("fake-client-hello")
frame := buildTCPSegment(src, dst, 100_000, 200_000, payload, true)
tcp := header.TCP(frame[header.IPv4MinimumSize:])
payloadChecksum := checksum.Checksum(payload, 0)
require.False(t, tcp.IsChecksumValid(
tcpip.AddrFrom4(src.Addr().As4()),
tcpip.AddrFrom4(dst.Addr().As4()),
payloadChecksum,
uint16(len(payload)),
))
// IP checksum must still be valid so the router forwards the packet.
require.True(t, header.IPv4(frame[:header.IPv4MinimumSize]).IsChecksumValid())
}
func TestBuildTCPSegment_IPv6_ValidChecksum(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("[fe80::1]:54321")
dst := netip.MustParseAddrPort("[2606:4700::1]:443")
payload := []byte("fake-client-hello")
frame := buildTCPSegment(src, dst, 0xDEADBEEF, 0x12345678, payload, false)
tcp := header.TCP(frame[header.IPv6MinimumSize:])
payloadChecksum := checksum.Checksum(payload, 0)
require.True(t, tcp.IsChecksumValid(
tcpip.AddrFrom16(src.Addr().As16()),
tcpip.AddrFrom16(dst.Addr().As16()),
payloadChecksum,
uint16(len(payload)),
))
}
func TestBuildTCPSegment_MixedFamilyPanics(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("10.0.0.1:54321")
dst := netip.MustParseAddrPort("[2606:4700::1]:443")
require.Panics(t, func() {
buildTCPSegment(src, dst, 0, 0, nil, false)
})
}
func TestBuildSpoofFrame_WrongSequence(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("10.0.0.1:54321")
dst := netip.MustParseAddrPort("1.2.3.4:443")
payload := []byte("fake-client-hello")
const sendNext uint32 = 10_000
frame, err := buildSpoofFrame(MethodWrongSequence, src, dst, sendNext, 20_000, payload)
require.NoError(t, err)
tcp := header.TCP(frame[header.IPv4MinimumSize:])
require.Equal(t, sendNext-uint32(len(payload)), tcp.SequenceNumber(),
"wrong-sequence places the fake at sendNext-len(payload)")
require.True(t, tcp.Flags().Contains(header.TCPFlagAck|header.TCPFlagPsh))
// Checksum must still be valid — only the sequence number is wrong.
payloadChecksum := checksum.Checksum(payload, 0)
require.True(t, tcp.IsChecksumValid(
tcpip.AddrFrom4(src.Addr().As4()),
tcpip.AddrFrom4(dst.Addr().As4()),
payloadChecksum,
uint16(len(payload)),
))
}
func TestBuildSpoofFrame_WrongChecksum(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("10.0.0.1:54321")
dst := netip.MustParseAddrPort("1.2.3.4:443")
payload := []byte("fake-client-hello")
const sendNext uint32 = 5_000
frame, err := buildSpoofFrame(MethodWrongChecksum, src, dst, sendNext, 20_000, payload)
require.NoError(t, err)
tcp := header.TCP(frame[header.IPv4MinimumSize:])
require.Equal(t, sendNext, tcp.SequenceNumber(),
"wrong-checksum keeps the real sequence number")
payloadChecksum := checksum.Checksum(payload, 0)
require.False(t, tcp.IsChecksumValid(
tcpip.AddrFrom4(src.Addr().As4()),
tcpip.AddrFrom4(dst.Addr().As4()),
payloadChecksum,
uint16(len(payload)),
))
require.True(t, header.IPv4(frame[:header.IPv4MinimumSize]).IsChecksumValid(),
"IPv4 checksum must remain valid so the router forwards the packet")
}
func TestBuildSpoofTCPSegment_EncodesWithoutIPHeader(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("[fe80::1]:54321")
dst := netip.MustParseAddrPort("[2606:4700::1]:443")
payload := []byte("fake-client-hello")
segment, err := buildSpoofTCPSegment(MethodWrongSequence, src, dst, 1000, 2000, payload)
require.NoError(t, err)
require.Equal(t, tcpHeaderLen+len(payload), len(segment),
"segment must be TCP header + payload, no IP header")
}

View File

@ -68,6 +68,9 @@ type darwinSpoofer struct {
}
func newRawSpoofer(conn net.Conn, method Method) (rawSpoofer, error) {
if method == MethodWrongTimestamp {
return nil, E.New("tls_spoof: wrong-timestamp is not supported on macOS")
}
_, src, dst, err := tcpEndpoints(conn)
if err != nil {
return nil, err
@ -159,7 +162,7 @@ func openDarwinRawSocket(src, dst netip.AddrPort) (int, unix.Sockaddr, error) {
func (s *darwinSpoofer) Inject(payload []byte) error {
if !s.src.Addr().Is4() {
segment, err := buildSpoofTCPSegment(s.method, s.src, s.dst, s.sendNext, s.receiveNext, payload)
segment, err := buildSpoofTCPSegment(s.method, s.src, s.dst, s.sendNext, s.receiveNext, 0, payload)
if err != nil {
return err
}
@ -169,7 +172,7 @@ func (s *darwinSpoofer) Inject(payload []byte) error {
}
return nil
}
frame, err := buildSpoofFrame(s.method, s.src, s.dst, s.sendNext, s.receiveNext, payload)
frame, err := buildSpoofFrame(s.method, s.src, s.dst, s.sendNext, s.receiveNext, 0, nil, payload)
if err != nil {
return err
}

View File

@ -27,6 +27,7 @@ type linuxSpoofer struct {
rawSockAddr unix.Sockaddr
sendNext uint32
receiveNext uint32
timestamp uint32
}
func newRawSpoofer(conn net.Conn, method Method) (rawSpoofer, error) {
@ -84,6 +85,15 @@ func openLinuxRawSocket(dst netip.AddrPort) (int, unix.Sockaddr, error) {
func (s *linuxSpoofer) loadSequenceNumbers(tcpConn *net.TCPConn) error {
return control.Conn(tcpConn, func(raw uintptr) (err error) {
fd := int(raw)
if s.method == MethodWrongTimestamp {
timestamp, err := unix.GetsockoptInt(fd, unix.IPPROTO_TCP, unix.TCP_TIMESTAMP)
if err != nil {
return E.Cause(err, "read timestamp")
}
s.timestamp = uint32(timestamp)
}
err = unix.SetsockoptInt(fd, unix.IPPROTO_TCP, unix.TCP_REPAIR, unix.TCP_REPAIR_ON)
if err != nil {
return E.Cause(err, "enter TCP_REPAIR (need CAP_NET_ADMIN)")
@ -118,7 +128,7 @@ func (s *linuxSpoofer) loadSequenceNumbers(tcpConn *net.TCPConn) error {
}
func (s *linuxSpoofer) Inject(payload []byte) error {
frame, err := buildSpoofFrame(s.method, s.src, s.dst, s.sendNext, s.receiveNext, payload)
frame, err := buildSpoofFrame(s.method, s.src, s.dst, s.sendNext, s.receiveNext, s.timestamp, nil, payload)
if err != nil {
return err
}

View File

@ -6,6 +6,7 @@ import (
"errors"
"net"
"net/netip"
"slices"
"sync"
"sync/atomic"
"time"
@ -113,7 +114,7 @@ func (s *windowsSpoofer) run() {
return
}
pkt := buf[:n]
seq, ack, payloadLen, ok := parseTCPFields(pkt, addr.IPv6())
seq, ack, tcpOptions, payloadLen, ok := parseTCPPacket(pkt, addr.IPv6())
if !ok {
// Our filter is OutboundTCP(src, dst); a non-TCP or truncated
// match means driver state is suspect. Re-inject so the kernel
@ -151,7 +152,11 @@ func (s *windowsSpoofer) run() {
continue
}
frame, err := buildSpoofFrame(s.method, s.src, s.dst, seq, ack, fake)
var timestamp uint32
if parsed := header.ParseTCPOptions(tcpOptions); parsed.TS {
timestamp = parsed.TSVal
}
frame, err := buildSpoofFrame(s.method, s.src, s.dst, seq, ack, timestamp, tcpOptions, fake)
if err != nil {
s.recordErr(err)
return
@ -177,46 +182,56 @@ func (s *windowsSpoofer) run() {
}
}
func parseTCPFields(pkt []byte, isV6 bool) (seq, ack uint32, payloadLen int, ok bool) {
func parseTCPPacket(pkt []byte, isV6 bool) (seq, ack uint32, options []byte, payloadLen int, ok bool) {
if isV6 {
if len(pkt) < header.IPv6MinimumSize+header.TCPMinimumSize {
return 0, 0, 0, false
return 0, 0, nil, 0, false
}
ip := header.IPv6(pkt)
if ip.TransportProtocol() != header.TCPProtocolNumber {
return 0, 0, 0, false
return 0, 0, nil, 0, false
}
tcp := header.TCP(pkt[header.IPv6MinimumSize:])
tcpHdr := int(tcp.DataOffset())
if tcpHdr < header.TCPMinimumSize || header.IPv6MinimumSize+tcpHdr > len(pkt) {
return 0, 0, 0, false
return 0, 0, nil, 0, false
}
return tcp.SequenceNumber(), tcp.AckNumber(),
len(pkt) - header.IPv6MinimumSize - tcpHdr, true
total := header.IPv6MinimumSize + int(ip.PayloadLength())
if total == header.IPv6MinimumSize || total > len(pkt) {
total = len(pkt)
}
if total < header.IPv6MinimumSize+tcpHdr {
return 0, 0, nil, 0, false
}
return tcp.SequenceNumber(), tcp.AckNumber(), slices.Clone(tcp.Options()),
total - header.IPv6MinimumSize - tcpHdr, true
}
if len(pkt) < header.IPv4MinimumSize+header.TCPMinimumSize {
return 0, 0, 0, false
return 0, 0, nil, 0, false
}
ip := header.IPv4(pkt)
if ip.Protocol() != uint8(header.TCPProtocolNumber) {
return 0, 0, 0, false
return 0, 0, nil, 0, false
}
ihl := int(ip.HeaderLength())
// ihl+TCPMinimumSize guards the TCP-header field reads below; without
// this, an IPv4 packet with options (ihl>20) against a 40-byte buffer
// reads past the TCP slice when calling DataOffset.
if ihl < header.IPv4MinimumSize || ihl+header.TCPMinimumSize > len(pkt) {
return 0, 0, 0, false
return 0, 0, nil, 0, false
}
tcp := header.TCP(pkt[ihl:])
tcpHdr := int(tcp.DataOffset())
if tcpHdr < header.TCPMinimumSize || ihl+tcpHdr > len(pkt) {
return 0, 0, 0, false
return 0, 0, nil, 0, false
}
total := int(ip.TotalLength())
if total == 0 || total > len(pkt) {
total = len(pkt)
}
return tcp.SequenceNumber(), tcp.AckNumber(),
if total < ihl+tcpHdr {
return 0, 0, nil, 0, false
}
return tcp.SequenceNumber(), tcp.AckNumber(), slices.Clone(tcp.Options()),
total - ihl - tcpHdr, true
}

View File

@ -1,112 +0,0 @@
//go:build windows && (amd64 || 386)
package tlsspoof
import (
"net/netip"
"testing"
"github.com/sagernet/sing-tun/gtcpip/header"
"github.com/stretchr/testify/require"
)
func TestParseTCPFieldsIPv4Valid(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("10.0.0.1:54321")
dst := netip.MustParseAddrPort("1.2.3.4:443")
payload := []byte("hello")
frame := buildTCPSegment(src, dst, 1000, 2000, payload, false)
seq, ack, payloadLen, ok := parseTCPFields(frame, false)
require.True(t, ok)
require.Equal(t, uint32(1000), seq)
require.Equal(t, uint32(2000), ack)
require.Equal(t, len(payload), payloadLen)
}
func TestParseTCPFieldsIPv4NoPayload(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("10.0.0.1:54321")
dst := netip.MustParseAddrPort("1.2.3.4:443")
frame := buildTCPSegment(src, dst, 42, 100, nil, false)
seq, ack, payloadLen, ok := parseTCPFields(frame, false)
require.True(t, ok)
require.Equal(t, uint32(42), seq)
require.Equal(t, uint32(100), ack)
require.Equal(t, 0, payloadLen)
}
func TestParseTCPFieldsIPv6Valid(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("[fe80::1]:54321")
dst := netip.MustParseAddrPort("[2606:4700::1]:443")
payload := []byte("hello-v6")
frame := buildTCPSegment(src, dst, 0xDEADBEEF, 0x12345678, payload, false)
seq, ack, payloadLen, ok := parseTCPFields(frame, true)
require.True(t, ok)
require.Equal(t, uint32(0xDEADBEEF), seq)
require.Equal(t, uint32(0x12345678), ack)
require.Equal(t, len(payload), payloadLen)
}
func TestParseTCPFieldsIPv4TooShort(t *testing.T) {
t.Parallel()
_, _, _, ok := parseTCPFields(make([]byte, header.IPv4MinimumSize+header.TCPMinimumSize-1), false)
require.False(t, ok)
}
func TestParseTCPFieldsIPv6TooShort(t *testing.T) {
t.Parallel()
_, _, _, ok := parseTCPFields(make([]byte, header.IPv6MinimumSize+header.TCPMinimumSize-1), true)
require.False(t, ok)
}
// buildTCPSegment only produces TCP; a UDP packet hitting parseTCPFields
// (for example from a mis-specified filter) must be rejected.
func TestParseTCPFieldsIPv4WrongProtocol(t *testing.T) {
t.Parallel()
frame := make([]byte, header.IPv4MinimumSize+header.TCPMinimumSize)
ip := header.IPv4(frame[:header.IPv4MinimumSize])
ip.Encode(&header.IPv4Fields{
TotalLength: uint16(len(frame)),
TTL: 64,
Protocol: 17, // UDP
SrcAddr: netip.MustParseAddr("10.0.0.1"),
DstAddr: netip.MustParseAddr("10.0.0.2"),
})
_, _, _, ok := parseTCPFields(frame, false)
require.False(t, ok)
}
func TestParseTCPFieldsIPv6WrongProtocol(t *testing.T) {
t.Parallel()
frame := make([]byte, header.IPv6MinimumSize+header.TCPMinimumSize)
ip := header.IPv6(frame[:header.IPv6MinimumSize])
ip.Encode(&header.IPv6Fields{
PayloadLength: header.TCPMinimumSize,
TransportProtocol: 17, // UDP
HopLimit: 64,
SrcAddr: netip.MustParseAddr("fe80::1"),
DstAddr: netip.MustParseAddr("fe80::2"),
})
_, _, _, ok := parseTCPFields(frame, true)
require.False(t, ok)
}
// ihl > 20 must not read past the TCP slice. Build an IPv4 packet with
// options header but truncate so ihl*4 + TCPMinimumSize exceeds len.
func TestParseTCPFieldsIPv4OptionsOverflow(t *testing.T) {
t.Parallel()
// Start with a valid IPv4+TCP frame, then lie about the header length.
src := netip.MustParseAddrPort("10.0.0.1:1")
dst := netip.MustParseAddrPort("10.0.0.2:2")
frame := buildTCPSegment(src, dst, 0, 0, []byte("x"), false)
ip := header.IPv4(frame[:header.IPv4MinimumSize])
// ihl=15 → 60 bytes of IP header claimed, but buffer only has 20.
ip.SetHeaderLength(60)
_, _, _, ok := parseTCPFields(frame, false)
require.False(t, ok)
}

View File

@ -11,19 +11,48 @@ type Method int
const (
MethodWrongSequence Method = iota
MethodWrongChecksum
MethodWrongAcknowledgment
MethodWrongMD5Sig
MethodWrongTimestamp
)
const (
MethodNameWrongSequence = "wrong-sequence"
MethodNameWrongChecksum = "wrong-checksum"
MethodNameWrongSequence = "wrong-sequence"
MethodNameWrongChecksum = "wrong-checksum"
MethodNameWrongAcknowledgment = "wrong-ack"
MethodNameWrongMD5Sig = "wrong-md5"
MethodNameWrongTimestamp = "wrong-timestamp"
)
func ParseOptions(spoof, method string) (string, Method, error) {
if spoof == "" {
if method != "" {
return "", 0, E.New("spoof_method requires spoof")
}
return "", 0, nil
}
if !PlatformSupported {
return "", 0, E.New("tls_spoof is not supported on this platform")
}
parsedMethod, err := ParseMethod(method)
if err != nil {
return "", 0, err
}
return spoof, parsedMethod, nil
}
func ParseMethod(s string) (Method, error) {
switch s {
case "", MethodNameWrongSequence:
return MethodWrongSequence, nil
case MethodNameWrongChecksum:
return MethodWrongChecksum, nil
case MethodNameWrongAcknowledgment:
return MethodWrongAcknowledgment, nil
case MethodNameWrongMD5Sig:
return MethodWrongMD5Sig, nil
case MethodNameWrongTimestamp:
return MethodWrongTimestamp, nil
default:
return 0, E.New("tls_spoof: unknown method: ", s)
}
@ -35,6 +64,12 @@ func (m Method) String() string {
return MethodNameWrongSequence
case MethodWrongChecksum:
return MethodNameWrongChecksum
case MethodWrongAcknowledgment:
return MethodNameWrongAcknowledgment
case MethodWrongMD5Sig:
return MethodNameWrongMD5Sig
case MethodWrongTimestamp:
return MethodNameWrongTimestamp
default:
return "unknown"
}

View File

@ -0,0 +1,4 @@
package tlsspoof
// realClientHello is a captured Chrome ClientHello for github.com.
const realClientHello = "16030105f8010005f403036e35de7389a679c54029cf452611f2211c70d9ac3897271de589ab6155f8e4ab20637d225f1ef969ad87ed78bfb9d171300bcb1703b6f314ccefb964f79b7d0961002a0a0a130213031301c02cc02bcca9c030c02fcca8c00ac009c014c013009d009c0035002fc008c012000a01000581baba00000000000f000d00000a6769746875622e636f6d00170000ff01000100000a000e000c3a3a11ec001d001700180019000b000201000010000e000c02683208687474702f312e31000500050100000000000d00160014040308040401050308050805050108060601020100120000003304ef04ed3a3a00010011ec04c0aeb2250c092a3463161cccb29d9183331a424964248579507ed23a180b0ceab2a5f5d9ce41547e497a89055471ea572867ba3a1fc3c9e45025274a20f60c6b60e62476b6afed0403af59ab83660ef4112ae20386a602010d0a5d454c0ed34c84ed4423e750213e6a2baab1bf9c4367a6007ab40a33d95220c2dcaa44f257024a5626b545db0510f4311b1a60714154909c6a61fdfca011fb2626d657aeb6070bf078508babe3b584555013e34acc56198ed4663742b3155a664a9901794c4586820a7dc162c01827291f3792e1237f801a8d1ef096013c181c4a58d2f6859ba75022d18cc4418bd4f351d5c18f83a58857d05af860c4b9ac018a5b63f17184e591532c6bc2cf2215d4a282c8a8a4f6f7aee110422c8bc9ebd3b1d609c568523aaae555db320e6c269473d87af38c256cbb9febc20aea6380c32a8916f7a373c8b1e37554e3260bf6621f6b804ee80b3c516b1d01985bf4c603b6daa9a5991de6a7a29f3a7122b8afb843a7660110fce62b43c615f5bcc2db688ba012649c0952b0a2c031e732d2b454c6b2968683cb8d244be2c9a7fa163222979eaf92722b92b862d81a3d94450c2b60c318421ebb4307c42d1f0473592a5c30e42039cc68cda9721e61aa63f49def17c15221680ed444896340133bbee67556f56b9f9d78a4df715f926a12add0cc9c862e46ea8b7316ae468282c18601b2771c9c9322f982228cf93effaacd3f80cbd12bce5fc36f56e2a3caf91e578a5fae00c9b23a8ed1a66764f4433c3628a70b8f0a6196adc60a4cb4226f07ba4c6b363fe9065563bfc1347452946386bab488686e837ab979c64f9047417fca635fe1bb4f074f256cc8af837c7b455e280426547755af90a61640169ef180aea3a77e662bb6dac1b6c3696027129b1a5edf495314e9c7f4b6110e16378ec893fa24642330a40aba1a85326101acb97c620fd8d71389e69eaed7bdb01bbe1fd428d66191150c7b2cd1ad4257391676a82ba8ce07fb2667c3b289f159003a7c7bc31d361b7b7f49a802961739d950dfcc0fa1c7abce5abdd2245101da391151490862028110465950b9e9c03d08a90998ab83267838d2e74a0593bc81f74cdf734519a05b351c0e5488c68dd810e6e9142ccc1e2f4a7f464297eb340e27acc6b9d64e12e38cce8492b3d939140b5a9e149a75597f10a23874c84323a07cdd657274378f887c85c4259b9c04cd33ba58ed630ef2a744f8e19dd34843dff331d2a6be7e2332c599289cd248a611c73d7481cd4a9bd43449a3836f14b2af18a1739e17999e4c67e85cc5bcecabb14185e5bcaff3c96098f03dc5aba819f29587758f49f940585354a2a780830528d68ccd166920dadcaa25cab5fc1907272a826aba3f08bc6b88757776812ecb6c7cec69a223ec0a13a7b62a2349a0f63ed7a27a3b15ba21d71fe6864ec6e089ae17cadd433fa3138f7ee24353c11365818f8fc34f43a05542d18efaac24bfccc1f748a0cc1a67ad379468b76fd34973dba785f5c91d618333cd810fe0700d1bbc8422029782628070a624c52c5309a4a64d625b11f8033ab28df34a1add297517fcc06b92b6817b3c5144438cf260867c57bde68c8c4b82e6a135ef676a52fbae5708002a404e6189a60e2836de565ad1b29e3819e5ed49f6810bcb28e1bd6de57306f94b79d9dae1cc4624d2a068499beef81cd5fe4b76dcbfff2a2008001d002001976128c6d5a934533f28b9914d2480aab2a8c1ab03d212529ce8b27640a716002d00020101002b000706caca03040303001b00030200015a5a000100"

View File

@ -10,7 +10,9 @@ icon: material/new-box
!!! quote "Changes in sing-box 1.14.0"
:material-plus: [resolve.disable_optimistic_cache](#disable_optimistic_cache)
:material-plus: [resolve.timeout](#timeout)
:material-plus: [resolve.timeout](#timeout)
:material-plus: [tls_spoof](#tls_spoof)
:material-plus: [tls_spoof_method](#tls_spoof_method)
!!! quote "Changes in sing-box 1.12.0"
@ -149,7 +151,9 @@ Not available when `method` is set to drop.
"udp_timeout": "",
"tls_fragment": false,
"tls_fragment_fallback_delay": "",
"tls_record_fragment": ""
"tls_record_fragment": "",
"tls_spoof": "",
"tls_spoof_method": ""
}
```
@ -248,6 +252,26 @@ The fallback value used when TLS segmentation cannot automatically determine the
Fragment TLS handshake into multiple TLS records to bypass firewalls.
#### tls_spoof
!!! question "Since sing-box 1.14.0"
==Linux/macOS/Windows only, requires elevated privileges==
Inject a forged TLS ClientHello carrying this SNI before the real one,
to fool SNI-filtering middleboxes that permit specific hostnames.
See outbound TLS [`spoof`](/configuration/shared/tls/#spoof) for details
and required privileges.
#### tls_spoof_method
!!! question "Since sing-box 1.14.0"
How the forged segment is rejected by the real server. See outbound TLS
[`spoof_method`](/configuration/shared/tls/#spoof_method) for the full table
of accepted values and platform notes.
### sniff
```json

View File

@ -10,7 +10,9 @@ icon: material/new-box
!!! quote "sing-box 1.14.0 中的更改"
:material-plus: [resolve.disable_optimistic_cache](#disable_optimistic_cache)
:material-plus: [resolve.timeout](#timeout)
:material-plus: [resolve.timeout](#timeout)
:material-plus: [tls_spoof](#tls_spoof)
:material-plus: [tls_spoof_method](#tls_spoof_method)
!!! quote "sing-box 1.12.0 中的更改"
@ -142,7 +144,9 @@ icon: material/new-box
"udp_timeout": "",
"tls_fragment": false,
"tls_fragment_fallback_delay": "",
"tls_record_fragment": false
"tls_record_fragment": false,
"tls_spoof": "",
"tls_spoof_method": ""
}
```
@ -240,6 +244,24 @@ UDP 连接超时时间。
通过分段 TLS 握手数据包到多个 TLS 记录来绕过防火墙检测。
#### tls_spoof
!!! question "自 sing-box 1.14.0 起"
==仅 Linux/macOS/Windows需要管理员权限==
在真实 ClientHello 之前注入携带本字段所指定 SNI 的伪造 TLS ClientHello
用于欺骗仅放行特定主机名的 SNI 过滤中间盒。
详情与所需权限参阅出站 TLS [`spoof`](/zh/configuration/shared/tls/#spoof)。
#### tls_spoof_method
!!! question "自 sing-box 1.14.0 起"
控制伪造报文被真实服务器拒绝的方式。完整取值表与平台说明参阅出站 TLS
[`spoof_method`](/zh/configuration/shared/tls/#spoof_method)。
### sniff
```json

View File

@ -702,12 +702,13 @@ driver on first use. Windows on ARM64 is not supported.
How the forged segment is rejected by the real server.
| Value | Behavior |
|----------------------------|----------------------------------------------------------------------------------------|
| `wrong-sequence` (default) | The forged segment's TCP sequence number is placed before the server's receive window. |
| `wrong-checksum` | The forged segment's TCP checksum is deliberately invalid. |
Conflict with `spoof` unset.
| Value | Behavior |
|----------------------------|----------------------------------------------------------------------------------------------------------------|
| `wrong-sequence` (default) | The forged segment's TCP sequence number is placed before the server's receive window. |
| `wrong-checksum` | The forged segment's TCP checksum is deliberately invalid. |
| `wrong-ack` | The forged segment's TCP acknowledgment number is placed before the server's send window. |
| `wrong-md5` | The forged segment carries a TCP-MD5 signature option, which the server rejects since no MD5 key is negotiated. |
| `wrong-timestamp` | The forged segment carries a backdated TCP timestamp, which the server rejects as a PAWS replay. Linux/Windows only; not supported on macOS. |
### ACME Fields

View File

@ -695,12 +695,13 @@ Windows 上首次使用时需要 Administrator 以安装内嵌的 WinDivert 内
控制伪造报文被真实服务器拒绝的方式。
| 取值 | 行为 |
|----------------------------|------------------------------------------------|
| `wrong-sequence`(默认) | 伪造报文的 TCP 序列号位于服务器接收窗口之前。 |
| `wrong-checksum` | 伪造报文的 TCP 校验和被故意设为无效。 |
`spoof` 未设置冲突。
| 取值 | 行为 |
|--------------------------|-------------------------------------------------------------------|
| `wrong-sequence`(默认) | 伪造报文的 TCP 序列号位于服务器接收窗口之前。 |
| `wrong-checksum` | 伪造报文的 TCP 校验和被故意设为无效。 |
| `wrong-ack` | 伪造报文的 TCP 确认号位于服务器发送窗口之前。 |
| `wrong-md5` | 伪造报文携带 TCP-MD5 签名选项,未协商 MD5 密钥的服务器将拒绝。 |
| `wrong-timestamp` | 伪造报文携带回退的 TCP 时间戳,服务器按 PAWS 规则视为重放并拒绝。仅支持 Linux/Windows不支持 macOS。 |
### ACME 字段

View File

@ -182,6 +182,8 @@ type RawRouteOptionsActionOptions struct {
TLSFragment bool `json:"tls_fragment,omitempty"`
TLSFragmentFallbackDelay badoption.Duration `json:"tls_fragment_fallback_delay,omitempty"`
TLSRecordFragment bool `json:"tls_record_fragment,omitempty"`
TLSSpoof string `json:"tls_spoof,omitempty"`
TLSSpoofMethod string `json:"tls_spoof_method,omitempty"`
}
type RouteOptionsActionOptions RawRouteOptionsActionOptions

View File

@ -15,6 +15,7 @@ import (
"github.com/sagernet/sing-box/common/dialer"
"github.com/sagernet/sing-box/common/sniff"
"github.com/sagernet/sing-box/common/tlsfragment"
"github.com/sagernet/sing-box/common/tlsspoof"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/buf"
@ -129,6 +130,17 @@ func (m *ConnectionManager) NewConnection(ctx context.Context, this N.Dialer, co
if metadata.TLSFragment || metadata.TLSRecordFragment {
remoteConn = tf.NewConn(remoteConn, ctx, metadata.TLSFragment, metadata.TLSRecordFragment, metadata.TLSFragmentFallbackDelay)
}
if metadata.TLSSpoof != "" {
spoofConn, spoofErr := tlsspoof.NewConn(remoteConn, metadata.TLSSpoofMethod, metadata.TLSSpoof)
if spoofErr != nil {
spoofErr = E.Cause(spoofErr, "tls_spoof setup")
remoteConn.Close()
N.CloseOnHandshakeFailure(conn, onClose, spoofErr)
m.logger.ErrorContext(ctx, spoofErr)
return
}
remoteConn = spoofConn
}
serverFirst := sniff.Skip(&metadata)
var done atomic.Bool
if m.kickWriteHandshake(ctx, conn, remoteConn, serverFirst, false, &done, onClose) {

View File

@ -489,6 +489,10 @@ match:
routeOptions = &action.RuleActionRouteOptions
case *R.RuleActionRouteOptions:
routeOptions = action
case *R.RuleActionBypass:
if action.Outbound != "" {
routeOptions = &action.RuleActionRouteOptions
}
}
if routeOptions != nil {
// TODO: add nat
@ -538,6 +542,10 @@ match:
if routeOptions.TLSRecordFragment {
metadata.TLSRecordFragment = true
}
if routeOptions.TLSSpoof != "" {
metadata.TLSSpoof = routeOptions.TLSSpoof
metadata.TLSSpoofMethod = routeOptions.TLSSpoofMethod
}
}
switch action := currentRule.Action().(type) {
case *R.RuleActionSniff:

View File

@ -11,6 +11,7 @@ import (
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/dialer"
"github.com/sagernet/sing-box/common/sniff"
"github.com/sagernet/sing-box/common/tlsspoof"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-tun"
@ -24,52 +25,54 @@ import (
"github.com/miekg/dns"
)
func newRuleActionRouteOptions(options option.RawRouteOptionsActionOptions) (RuleActionRouteOptions, error) {
spoof, spoofMethod, err := tlsspoof.ParseOptions(options.TLSSpoof, options.TLSSpoofMethod)
if err != nil {
return RuleActionRouteOptions{}, err
}
return RuleActionRouteOptions{
OverrideAddress: M.ParseSocksaddrHostPort(options.OverrideAddress, 0),
OverridePort: options.OverridePort,
NetworkStrategy: (*C.NetworkStrategy)(options.NetworkStrategy),
FallbackDelay: time.Duration(options.FallbackDelay),
UDPDisableDomainUnmapping: options.UDPDisableDomainUnmapping,
UDPConnect: options.UDPConnect,
UDPTimeout: time.Duration(options.UDPTimeout),
TLSFragment: options.TLSFragment,
TLSFragmentFallbackDelay: time.Duration(options.TLSFragmentFallbackDelay),
TLSRecordFragment: options.TLSRecordFragment,
TLSSpoof: spoof,
TLSSpoofMethod: spoofMethod,
}, nil
}
func NewRuleAction(ctx context.Context, logger logger.ContextLogger, action option.RuleAction) (adapter.RuleAction, error) {
switch action.Action {
case "":
return nil, nil
case C.RuleActionTypeRoute:
routeOptions, err := newRuleActionRouteOptions(action.RouteOptions.RawRouteOptionsActionOptions)
if err != nil {
return nil, err
}
return &RuleActionRoute{
Outbound: action.RouteOptions.Outbound,
RuleActionRouteOptions: RuleActionRouteOptions{
OverrideAddress: M.ParseSocksaddrHostPort(action.RouteOptions.OverrideAddress, 0),
OverridePort: action.RouteOptions.OverridePort,
NetworkStrategy: (*C.NetworkStrategy)(action.RouteOptions.NetworkStrategy),
FallbackDelay: time.Duration(action.RouteOptions.FallbackDelay),
UDPDisableDomainUnmapping: action.RouteOptions.UDPDisableDomainUnmapping,
UDPConnect: action.RouteOptions.UDPConnect,
TLSFragment: action.RouteOptions.TLSFragment,
TLSFragmentFallbackDelay: time.Duration(action.RouteOptions.TLSFragmentFallbackDelay),
TLSRecordFragment: action.RouteOptions.TLSRecordFragment,
},
Outbound: action.RouteOptions.Outbound,
RuleActionRouteOptions: routeOptions,
}, nil
case C.RuleActionTypeRouteOptions:
return &RuleActionRouteOptions{
OverrideAddress: M.ParseSocksaddrHostPort(action.RouteOptionsOptions.OverrideAddress, 0),
OverridePort: action.RouteOptionsOptions.OverridePort,
NetworkStrategy: (*C.NetworkStrategy)(action.RouteOptionsOptions.NetworkStrategy),
FallbackDelay: time.Duration(action.RouteOptionsOptions.FallbackDelay),
UDPDisableDomainUnmapping: action.RouteOptionsOptions.UDPDisableDomainUnmapping,
UDPConnect: action.RouteOptionsOptions.UDPConnect,
UDPTimeout: time.Duration(action.RouteOptionsOptions.UDPTimeout),
TLSFragment: action.RouteOptionsOptions.TLSFragment,
TLSFragmentFallbackDelay: time.Duration(action.RouteOptionsOptions.TLSFragmentFallbackDelay),
TLSRecordFragment: action.RouteOptionsOptions.TLSRecordFragment,
}, nil
routeOptions, err := newRuleActionRouteOptions(option.RawRouteOptionsActionOptions(action.RouteOptionsOptions))
if err != nil {
return nil, err
}
return &routeOptions, nil
case C.RuleActionTypeBypass:
routeOptions, err := newRuleActionRouteOptions(action.BypassOptions.RawRouteOptionsActionOptions)
if err != nil {
return nil, err
}
return &RuleActionBypass{
Outbound: action.BypassOptions.Outbound,
RuleActionRouteOptions: RuleActionRouteOptions{
OverrideAddress: M.ParseSocksaddrHostPort(action.BypassOptions.OverrideAddress, 0),
OverridePort: action.BypassOptions.OverridePort,
NetworkStrategy: (*C.NetworkStrategy)(action.BypassOptions.NetworkStrategy),
FallbackDelay: time.Duration(action.BypassOptions.FallbackDelay),
UDPDisableDomainUnmapping: action.BypassOptions.UDPDisableDomainUnmapping,
UDPConnect: action.BypassOptions.UDPConnect,
TLSFragment: action.BypassOptions.TLSFragment,
TLSFragmentFallbackDelay: time.Duration(action.BypassOptions.TLSFragmentFallbackDelay),
TLSRecordFragment: action.BypassOptions.TLSRecordFragment,
},
Outbound: action.BypassOptions.Outbound,
RuleActionRouteOptions: routeOptions,
}, nil
case C.RuleActionTypeDirect:
directDialer, err := dialer.New(ctx, option.DialerOptions(action.DirectOptions), false)
@ -225,6 +228,8 @@ type RuleActionRouteOptions struct {
TLSFragment bool
TLSFragmentFallbackDelay time.Duration
TLSRecordFragment bool
TLSSpoof string
TLSSpoofMethod tlsspoof.Method
}
func (r *RuleActionRouteOptions) Type() string {
@ -273,6 +278,10 @@ func (r *RuleActionRouteOptions) Descriptions() []string {
if r.TLSRecordFragment {
descriptions = append(descriptions, "tls-record-fragment")
}
if r.TLSSpoof != "" {
descriptions = append(descriptions, F.ToString("tls-spoof=", r.TLSSpoof))
descriptions = append(descriptions, F.ToString("tls-spoof-method=", r.TLSSpoofMethod.String()))
}
return descriptions
}