From a06097c2c45fabbb53c27d4bf9646671f41ed594 Mon Sep 17 00:00:00 2001 From: saba-futai <120904569+saba-futai@users.noreply.github.com> Date: Tue, 16 Dec 2025 18:39:39 +0800 Subject: [PATCH] chore: add xvp rotation andd new header generation strategy for sudoku (#2437) --- adapter/outbound/sudoku.go | 42 +++--- docs/config.yaml | 3 + go.mod | 2 +- go.sum | 4 +- listener/config/sudoku.go | 21 +-- listener/inbound/sudoku.go | 18 +-- listener/sudoku/server.go | 8 +- transport/sudoku/features_test.go | 192 ++++++++++++++++++++++++++ transport/sudoku/handshake.go | 90 +++++++++--- transport/sudoku/httpmask_strategy.go | 179 ++++++++++++++++++++++++ transport/sudoku/obfs_writer.go | 113 +++++++++++++++ transport/sudoku/table_probe.go | 152 ++++++++++++++++++++ transport/sudoku/tables.go | 30 ++++ 13 files changed, 798 insertions(+), 56 deletions(-) create mode 100644 transport/sudoku/features_test.go create mode 100644 transport/sudoku/httpmask_strategy.go create mode 100644 transport/sudoku/obfs_writer.go create mode 100644 transport/sudoku/table_probe.go create mode 100644 transport/sudoku/tables.go diff --git a/adapter/outbound/sudoku.go b/adapter/outbound/sudoku.go index f9313ca3..bd393ec6 100644 --- a/adapter/outbound/sudoku.go +++ b/adapter/outbound/sudoku.go @@ -20,17 +20,19 @@ type Sudoku struct { type SudokuOption struct { BasicOption - Name string `proxy:"name"` - Server string `proxy:"server"` - Port int `proxy:"port"` - Key string `proxy:"key"` - AEADMethod string `proxy:"aead-method,omitempty"` - PaddingMin *int `proxy:"padding-min,omitempty"` - PaddingMax *int `proxy:"padding-max,omitempty"` - TableType string `proxy:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy" - EnablePureDownlink *bool `proxy:"enable-pure-downlink,omitempty"` - HTTPMask bool `proxy:"http-mask,omitempty"` - CustomTable string `proxy:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv + Name string `proxy:"name"` + Server string `proxy:"server"` + Port int `proxy:"port"` + Key string `proxy:"key"` + AEADMethod string `proxy:"aead-method,omitempty"` + PaddingMin *int `proxy:"padding-min,omitempty"` + PaddingMax *int `proxy:"padding-max,omitempty"` + TableType string `proxy:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy" + EnablePureDownlink *bool `proxy:"enable-pure-downlink,omitempty"` + HTTPMask bool `proxy:"http-mask,omitempty"` + HTTPMaskStrategy string `proxy:"http-mask-strategy,omitempty"` // "random" (default), "post", "websocket" + CustomTable string `proxy:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv + CustomTables []string `proxy:"custom-tables,omitempty"` // optional table rotation patterns, overrides custom-table when non-empty } // DialContext implements C.ProxyAdapter @@ -54,7 +56,9 @@ func (s *Sudoku) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Con defer done(&err) } - c, err = sudoku.ClientHandshake(c, cfg) + c, err = sudoku.ClientHandshakeWithOptions(c, cfg, sudoku.ClientHandshakeOptions{ + HTTPMaskStrategy: s.option.HTTPMaskStrategy, + }) if err != nil { return nil, err } @@ -97,7 +101,9 @@ func (s *Sudoku) ListenPacketContext(ctx context.Context, metadata *C.Metadata) defer done(&err) } - c, err = sudoku.ClientHandshake(c, cfg) + c, err = sudoku.ClientHandshakeWithOptions(c, cfg, sudoku.ClientHandshakeOptions{ + HTTPMaskStrategy: s.option.HTTPMaskStrategy, + }) if err != nil { return nil, err } @@ -185,11 +191,15 @@ func NewSudoku(option SudokuOption) (*Sudoku, error) { HandshakeTimeoutSeconds: defaultConf.HandshakeTimeoutSeconds, DisableHTTPMask: !option.HTTPMask, } - table, err := sudoku.NewTableWithCustom(sudoku.ClientAEADSeed(option.Key), tableType, option.CustomTable) + tables, err := sudoku.NewTablesWithCustomPatterns(sudoku.ClientAEADSeed(option.Key), tableType, option.CustomTable, option.CustomTables) if err != nil { - return nil, fmt.Errorf("build table failed: %w", err) + return nil, fmt.Errorf("build table(s) failed: %w", err) + } + if len(tables) == 1 { + baseConf.Table = tables[0] + } else { + baseConf.Tables = tables } - baseConf.Table = table if option.AEADMethod != "" { baseConf.AEADMethod = option.AEADMethod } diff --git a/docs/config.yaml b/docs/config.yaml index 26b1af2c..04d15bd2 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -1049,7 +1049,9 @@ proxies: # socks5 padding-max: 7 # 最大填充字节数 table-type: prefer_ascii # 可选值:prefer_ascii、prefer_entropy 前者全ascii映射,后者保证熵值(汉明1)低于3 # custom-table: xpxvvpvv # 可选,自定义字节布局,必须包含2个x、2个p、4个v,可随意组合。启用此处则需配置`table-type`为`prefer_entropy` + # custom-tables: ["xpxvvpvv", "vxpvxvvp"] # 可选,自定义字节布局列表(x/v/p),用于 xvp 模式轮换;非空时覆盖 custom-table http-mask: true # 是否启用http掩码 + # http-mask-strategy: random # 可选:random(默认)、post、websocket;仅在 http-mask=true 时生效 enable-pure-downlink: false # 是否启用混淆下行,false的情况下能在保证数据安全的前提下极大提升下行速度,与服务端端保持相同(如果此处为false,则要求aead不可为none) # anytls @@ -1591,6 +1593,7 @@ listeners: padding-max: 15 # 填充最大长度,均不建议过大 table-type: prefer_ascii # 可选值:prefer_ascii、prefer_entropy 前者全ascii映射,后者保证熵值(汉明1)低于3 # custom-table: xpxvvpvv # 可选,自定义字节布局,必须包含2个x、2个p、4个v,可随意组合。启用此处则需配置`table-type`为`prefer_entropy` + # custom-tables: ["xpxvvpvv", "vxpvxvvp"] # 可选,自定义字节布局列表(x/v/p),用于 xvp 模式轮换;非空时覆盖 custom-table handshake-timeout: 5 # optional enable-pure-downlink: false # 是否启用混淆下行,false的情况下能在保证数据安全的前提下极大提升下行速度,与客户端保持相同(如果此处为false,则要求aead不可为none) diff --git a/go.mod b/go.mod index 8fb7e51f..c0160c26 100644 --- a/go.mod +++ b/go.mod @@ -43,7 +43,7 @@ require ( github.com/mroth/weightedrand/v2 v2.1.0 github.com/openacid/low v0.1.21 github.com/oschwald/maxminddb-golang v1.12.0 // lastest version compatible with golang1.20 - github.com/saba-futai/sudoku v0.0.2-c + github.com/saba-futai/sudoku v0.0.2-d github.com/sagernet/cors v1.2.1 github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a github.com/samber/lo v1.52.0 diff --git a/go.sum b/go.sum index 08d1d002..c6fba8e5 100644 --- a/go.sum +++ b/go.sum @@ -171,8 +171,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo= github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A= -github.com/saba-futai/sudoku v0.0.2-c h1:0CaoCKx4Br8UL97fnIxn8Y7rnQpflBza7kfaIrdg2rI= -github.com/saba-futai/sudoku v0.0.2-c/go.mod h1:Rvggsoprp7HQM7bMIZUd1M27bPj8THRsZdY1dGbIAvo= +github.com/saba-futai/sudoku v0.0.2-d h1:HW/gIyNUFcDchpMN+ZhluM86U/HGkWkkRV+9Km6WZM8= +github.com/saba-futai/sudoku v0.0.2-d/go.mod h1:Rvggsoprp7HQM7bMIZUd1M27bPj8THRsZdY1dGbIAvo= github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ= github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis= diff --git a/listener/config/sudoku.go b/listener/config/sudoku.go index b581f3f5..848db875 100644 --- a/listener/config/sudoku.go +++ b/listener/config/sudoku.go @@ -9,16 +9,17 @@ import ( // SudokuServer describes a Sudoku inbound server configuration. // It is internal to the listener layer and mainly used for logging and wiring. type SudokuServer struct { - Enable bool `json:"enable"` - Listen string `json:"listen"` - Key string `json:"key"` - AEADMethod string `json:"aead-method,omitempty"` - PaddingMin *int `json:"padding-min,omitempty"` - PaddingMax *int `json:"padding-max,omitempty"` - TableType string `json:"table-type,omitempty"` - HandshakeTimeoutSecond *int `json:"handshake-timeout,omitempty"` - EnablePureDownlink *bool `json:"enable-pure-downlink,omitempty"` - CustomTable string `json:"custom-table,omitempty"` + Enable bool `json:"enable"` + Listen string `json:"listen"` + Key string `json:"key"` + AEADMethod string `json:"aead-method,omitempty"` + PaddingMin *int `json:"padding-min,omitempty"` + PaddingMax *int `json:"padding-max,omitempty"` + TableType string `json:"table-type,omitempty"` + HandshakeTimeoutSecond *int `json:"handshake-timeout,omitempty"` + EnablePureDownlink *bool `json:"enable-pure-downlink,omitempty"` + CustomTable string `json:"custom-table,omitempty"` + CustomTables []string `json:"custom-tables,omitempty"` // mihomo private extension (not the part of standard Sudoku protocol) MuxOption sing.MuxOption `json:"mux-option,omitempty"` diff --git a/listener/inbound/sudoku.go b/listener/inbound/sudoku.go index 6c409247..43397602 100644 --- a/listener/inbound/sudoku.go +++ b/listener/inbound/sudoku.go @@ -13,14 +13,15 @@ import ( type SudokuOption struct { BaseOption - Key string `inbound:"key"` - AEADMethod string `inbound:"aead-method,omitempty"` - PaddingMin *int `inbound:"padding-min,omitempty"` - PaddingMax *int `inbound:"padding-max,omitempty"` - TableType string `inbound:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy" - HandshakeTimeoutSecond *int `inbound:"handshake-timeout,omitempty"` - EnablePureDownlink *bool `inbound:"enable-pure-downlink,omitempty"` - CustomTable string `inbound:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv + Key string `inbound:"key"` + AEADMethod string `inbound:"aead-method,omitempty"` + PaddingMin *int `inbound:"padding-min,omitempty"` + PaddingMax *int `inbound:"padding-max,omitempty"` + TableType string `inbound:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy" + HandshakeTimeoutSecond *int `inbound:"handshake-timeout,omitempty"` + EnablePureDownlink *bool `inbound:"enable-pure-downlink,omitempty"` + CustomTable string `inbound:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv + CustomTables []string `inbound:"custom-tables,omitempty"` // mihomo private extension (not the part of standard Sudoku protocol) MuxOption MuxOption `inbound:"mux-option,omitempty"` @@ -57,6 +58,7 @@ func NewSudoku(options *SudokuOption) (*Sudoku, error) { HandshakeTimeoutSecond: options.HandshakeTimeoutSecond, EnablePureDownlink: options.EnablePureDownlink, CustomTable: options.CustomTable, + CustomTables: options.CustomTables, } serverConf.MuxOption = options.MuxOption.Build() diff --git a/listener/sudoku/server.go b/listener/sudoku/server.go index 8351e365..e90e231c 100644 --- a/listener/sudoku/server.go +++ b/listener/sudoku/server.go @@ -166,7 +166,7 @@ func New(config LC.SudokuServer, tunnel C.Tunnel, additions ...inbound.Addition) enablePureDownlink = *config.EnablePureDownlink } - table, err := sudoku.NewTableWithCustom(config.Key, tableType, config.CustomTable) + tables, err := sudoku.NewTablesWithCustomPatterns(config.Key, tableType, config.CustomTable, config.CustomTables) if err != nil { _ = l.Close() return nil, err @@ -180,12 +180,16 @@ func New(config LC.SudokuServer, tunnel C.Tunnel, additions ...inbound.Addition) protoConf := sudoku.ProtocolConfig{ Key: config.Key, AEADMethod: defaultConf.AEADMethod, - Table: table, PaddingMin: paddingMin, PaddingMax: paddingMax, EnablePureDownlink: enablePureDownlink, HandshakeTimeoutSeconds: handshakeTimeout, } + if len(tables) == 1 { + protoConf.Table = tables[0] + } else { + protoConf.Tables = tables + } if config.AEADMethod != "" { protoConf.AEADMethod = config.AEADMethod } diff --git a/transport/sudoku/features_test.go b/transport/sudoku/features_test.go new file mode 100644 index 00000000..8eb3aedd --- /dev/null +++ b/transport/sudoku/features_test.go @@ -0,0 +1,192 @@ +package sudoku + +import ( + "bytes" + "io" + "net" + "testing" + "time" + + sudokuobfs "github.com/saba-futai/sudoku/pkg/obfs/sudoku" +) + +type discardConn struct{} + +func (discardConn) Read([]byte) (int, error) { return 0, io.EOF } +func (discardConn) Write(p []byte) (int, error) { return len(p), nil } +func (discardConn) Close() error { return nil } +func (discardConn) LocalAddr() net.Addr { return nil } +func (discardConn) RemoteAddr() net.Addr { return nil } +func (discardConn) SetDeadline(time.Time) error { return nil } +func (discardConn) SetReadDeadline(time.Time) error { return nil } +func (discardConn) SetWriteDeadline(time.Time) error { return nil } + +func TestSudokuObfsWriter_ReducesWriteAllocs(t *testing.T) { + table := sudokuobfs.NewTable("alloc-seed", "prefer_ascii") + w := newSudokuObfsWriter(discardConn{}, table, 0, 0) + + payload := bytes.Repeat([]byte{0x42}, 2048) + if _, err := w.Write(payload); err != nil { + t.Fatalf("warmup write: %v", err) + } + + allocs := testing.AllocsPerRun(100, func() { + if _, err := w.Write(payload); err != nil { + t.Fatalf("write: %v", err) + } + }) + if allocs != 0 { + t.Fatalf("expected 0 allocs/run, got %.2f", allocs) + } +} + +func TestHTTPMaskStrategy_WebSocketAndPost(t *testing.T) { + key := "mask-test-key" + target := "1.1.1.1:80" + table := sudokuobfs.NewTable("mask-seed", "prefer_ascii") + + base := DefaultConfig() + base.Key = key + base.AEADMethod = "chacha20-poly1305" + base.Table = table + base.PaddingMin = 0 + base.PaddingMax = 0 + base.EnablePureDownlink = true + base.HandshakeTimeoutSeconds = 5 + base.DisableHTTPMask = false + base.ServerAddress = "example.com:443" + + cases := []string{"post", "websocket"} + for _, strategy := range cases { + t.Run(strategy, func(t *testing.T) { + serverConn, clientConn := net.Pipe() + defer serverConn.Close() + defer clientConn.Close() + + errCh := make(chan error, 1) + go func() { + defer close(errCh) + session, err := ServerHandshake(serverConn, base) + if err != nil { + errCh <- err + return + } + defer session.Conn.Close() + if session.Type != SessionTypeTCP { + errCh <- io.ErrUnexpectedEOF + return + } + if session.Target != target { + errCh <- io.ErrClosedPipe + return + } + _, _ = session.Conn.Write([]byte("ok")) + }() + + cConn, err := ClientHandshakeWithOptions(clientConn, base, ClientHandshakeOptions{HTTPMaskStrategy: strategy}) + if err != nil { + t.Fatalf("client handshake: %v", err) + } + defer cConn.Close() + + addrBuf, err := EncodeAddress(target) + if err != nil { + t.Fatalf("encode addr: %v", err) + } + if _, err := cConn.Write(addrBuf); err != nil { + t.Fatalf("write addr: %v", err) + } + + buf := make([]byte, 2) + if _, err := io.ReadFull(cConn, buf); err != nil { + t.Fatalf("read: %v", err) + } + if string(buf) != "ok" { + t.Fatalf("unexpected payload: %q", buf) + } + + if err := <-errCh; err != nil { + t.Fatalf("server: %v", err) + } + }) + } +} + +func TestCustomTablesRotation_ProbedByServer(t *testing.T) { + key := "rotate-test-key" + target := "8.8.8.8:53" + + t1, err := sudokuobfs.NewTableWithCustom("rotate-seed", "prefer_entropy", "xpxvvpvv") + if err != nil { + t.Fatalf("t1: %v", err) + } + t2, err := sudokuobfs.NewTableWithCustom("rotate-seed", "prefer_entropy", "vxpvxvvp") + if err != nil { + t.Fatalf("t2: %v", err) + } + + serverCfg := DefaultConfig() + serverCfg.Key = key + serverCfg.AEADMethod = "chacha20-poly1305" + serverCfg.Tables = []*sudokuobfs.Table{t1, t2} + serverCfg.PaddingMin = 0 + serverCfg.PaddingMax = 0 + serverCfg.EnablePureDownlink = true + serverCfg.HandshakeTimeoutSeconds = 5 + serverCfg.DisableHTTPMask = true + + clientCfg := DefaultConfig() + *clientCfg = *serverCfg + clientCfg.ServerAddress = "example.com:443" + + for i := 0; i < 10; i++ { + serverConn, clientConn := net.Pipe() + + errCh := make(chan error, 1) + go func() { + defer close(errCh) + defer serverConn.Close() + session, err := ServerHandshake(serverConn, serverCfg) + if err != nil { + errCh <- err + return + } + defer session.Conn.Close() + if session.Type != SessionTypeTCP { + errCh <- io.ErrUnexpectedEOF + return + } + if session.Target != target { + errCh <- io.ErrClosedPipe + return + } + _, _ = session.Conn.Write([]byte{0xaa, 0xbb, 0xcc}) + }() + + cConn, err := ClientHandshake(clientConn, clientCfg) + if err != nil { + t.Fatalf("client handshake: %v", err) + } + + addrBuf, err := EncodeAddress(target) + if err != nil { + t.Fatalf("encode addr: %v", err) + } + if _, err := cConn.Write(addrBuf); err != nil { + t.Fatalf("write addr: %v", err) + } + + buf := make([]byte, 3) + if _, err := io.ReadFull(cConn, buf); err != nil { + t.Fatalf("read: %v", err) + } + if !bytes.Equal(buf, []byte{0xaa, 0xbb, 0xcc}) { + t.Fatalf("payload mismatch: %x", buf) + } + _ = cConn.Close() + + if err := <-errCh; err != nil { + t.Fatalf("server: %v", err) + } + } +} diff --git a/transport/sudoku/handshake.go b/transport/sudoku/handshake.go index d34fceb4..989d2813 100644 --- a/transport/sudoku/handshake.go +++ b/transport/sudoku/handshake.go @@ -2,11 +2,13 @@ package sudoku import ( "bufio" + "crypto/rand" "crypto/sha256" "encoding/binary" "fmt" "io" "net" + "strings" "time" "github.com/saba-futai/sudoku/apis" @@ -110,25 +112,35 @@ func downlinkMode(cfg *apis.ProtocolConfig) byte { return downlinkModePacked } -func buildClientObfsConn(raw net.Conn, cfg *apis.ProtocolConfig) net.Conn { - base := sudoku.NewConn(raw, cfg.Table, cfg.PaddingMin, cfg.PaddingMax, false) +func buildClientObfsConn(raw net.Conn, cfg *apis.ProtocolConfig, table *sudoku.Table) net.Conn { + baseReader := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false) + baseWriter := newSudokuObfsWriter(raw, table, cfg.PaddingMin, cfg.PaddingMax) if cfg.EnablePureDownlink { - return base + return &directionalConn{ + Conn: raw, + reader: baseReader, + writer: baseWriter, + } } - packed := sudoku.NewPackedConn(raw, cfg.Table, cfg.PaddingMin, cfg.PaddingMax) + packed := sudoku.NewPackedConn(raw, table, cfg.PaddingMin, cfg.PaddingMax) return &directionalConn{ Conn: raw, reader: packed, - writer: base, + writer: baseWriter, } } -func buildServerObfsConn(raw net.Conn, cfg *apis.ProtocolConfig, record bool) (*sudoku.Conn, net.Conn) { - uplink := sudoku.NewConn(raw, cfg.Table, cfg.PaddingMin, cfg.PaddingMax, record) +func buildServerObfsConn(raw net.Conn, cfg *apis.ProtocolConfig, table *sudoku.Table, record bool) (*sudoku.Conn, net.Conn) { + uplink := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, record) if cfg.EnablePureDownlink { - return uplink, uplink + downlink := &directionalConn{ + Conn: raw, + reader: uplink, + writer: newSudokuObfsWriter(raw, table, cfg.PaddingMin, cfg.PaddingMax), + } + return uplink, downlink } - packed := sudoku.NewPackedConn(raw, cfg.Table, cfg.PaddingMin, cfg.PaddingMax) + packed := sudoku.NewPackedConn(raw, table, cfg.PaddingMin, cfg.PaddingMax) return uplink, &directionalConn{ Conn: raw, reader: uplink, @@ -170,8 +182,19 @@ func ClientAEADSeed(key string) string { return key } +type ClientHandshakeOptions struct { + // HTTPMaskStrategy controls how the client generates the HTTP mask header when DisableHTTPMask=false. + // Supported: ""/"random" (default), "post", "websocket". + HTTPMaskStrategy string +} + // ClientHandshake performs the client-side Sudoku handshake (without sending target address). func ClientHandshake(rawConn net.Conn, cfg *apis.ProtocolConfig) (net.Conn, error) { + return ClientHandshakeWithOptions(rawConn, cfg, ClientHandshakeOptions{}) +} + +// ClientHandshakeWithOptions performs the client-side Sudoku handshake (without sending target address). +func ClientHandshakeWithOptions(rawConn net.Conn, cfg *apis.ProtocolConfig, opt ClientHandshakeOptions) (net.Conn, error) { if cfg == nil { return nil, fmt.Errorf("config is required") } @@ -180,18 +203,26 @@ func ClientHandshake(rawConn net.Conn, cfg *apis.ProtocolConfig) (net.Conn, erro } if !cfg.DisableHTTPMask { - if err := httpmask.WriteRandomRequestHeader(rawConn, cfg.ServerAddress); err != nil { + if err := WriteHTTPMaskHeader(rawConn, cfg.ServerAddress, opt.HTTPMaskStrategy); err != nil { return nil, fmt.Errorf("write http mask failed: %w", err) } } - obfsConn := buildClientObfsConn(rawConn, cfg) + table, tableID, err := pickClientTable(cfg) + if err != nil { + return nil, err + } + + obfsConn := buildClientObfsConn(rawConn, cfg, table) cConn, err := crypto.NewAEADConn(obfsConn, ClientAEADSeed(cfg.Key), cfg.AEADMethod) if err != nil { return nil, fmt.Errorf("setup crypto failed: %w", err) } handshake := buildHandshakePayload(cfg.Key) + if len(tableCandidates(cfg)) > 1 { + handshake[15] = tableID + } if _, err := cConn.Write(handshake[:]); err != nil { cConn.Close() return nil, fmt.Errorf("send handshake failed: %w", err) @@ -218,21 +249,25 @@ func ServerHandshake(rawConn net.Conn, cfg *apis.ProtocolConfig) (*ServerSession handshakeTimeout = 5 * time.Second } + rawConn.SetReadDeadline(time.Now().Add(handshakeTimeout)) + bufReader := bufio.NewReader(rawConn) if !cfg.DisableHTTPMask { - if peek, _ := bufReader.Peek(4); len(peek) == 4 && string(peek) == "POST" { + if peek, err := bufReader.Peek(4); err == nil && httpmask.LooksLikeHTTPRequestStart(peek) { if _, err := httpmask.ConsumeHeader(bufReader); err != nil { return nil, fmt.Errorf("invalid http header: %w", err) } } } - rawConn.SetReadDeadline(time.Now().Add(handshakeTimeout)) - bConn := &bufferedConn{ - Conn: rawConn, - r: bufReader, + selectedTable, preRead, err := selectTableByProbe(bufReader, cfg, tableCandidates(cfg)) + if err != nil { + return nil, err } - sConn, obfsConn := buildServerObfsConn(bConn, cfg, true) + + baseConn := &preBufferedConn{Conn: rawConn, buf: preRead} + bConn := &bufferedConn{Conn: baseConn, r: bufio.NewReader(baseConn)} + sConn, obfsConn := buildServerObfsConn(bConn, cfg, selectedTable, true) cConn, err := crypto.NewAEADConn(obfsConn, cfg.Key, cfg.AEADMethod) if err != nil { return nil, fmt.Errorf("crypto setup failed: %w", err) @@ -313,3 +348,24 @@ func GenKeyPair() (privateKey, publicKey string, err error) { publicKey = crypto.EncodePoint(pair.Public) // Master Public Key for server return } + +func normalizeHTTPMaskStrategy(strategy string) string { + s := strings.TrimSpace(strings.ToLower(strategy)) + switch s { + case "", "random": + return "random" + case "ws": + return "websocket" + default: + return s + } +} + +// randomByte returns a cryptographically random byte (with a math/rand fallback). +func randomByte() byte { + var b [1]byte + if _, err := rand.Read(b[:]); err == nil { + return b[0] + } + return byte(time.Now().UnixNano()) +} diff --git a/transport/sudoku/httpmask_strategy.go b/transport/sudoku/httpmask_strategy.go new file mode 100644 index 00000000..dc90991d --- /dev/null +++ b/transport/sudoku/httpmask_strategy.go @@ -0,0 +1,179 @@ +package sudoku + +import ( + "encoding/base64" + "fmt" + "io" + "math/rand" + "net" + "strconv" + "sync" + "time" + + "github.com/saba-futai/sudoku/pkg/obfs/httpmask" +) + +var ( + httpMaskUserAgents = []string{ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + } + httpMaskAccepts = []string{ + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "application/json, text/plain, */*", + "application/octet-stream", + "*/*", + } + httpMaskAcceptLanguages = []string{ + "en-US,en;q=0.9", + "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", + } + httpMaskAcceptEncodings = []string{ + "gzip, deflate, br", + "gzip, deflate", + } + httpMaskPaths = []string{ + "/api/v1/upload", + "/data/sync", + "/v1/telemetry", + "/session", + "/ws", + } + httpMaskContentTypes = []string{ + "application/octet-stream", + "application/json", + } +) + +var ( + httpMaskRngPool = sync.Pool{ + New: func() any { return rand.New(rand.NewSource(time.Now().UnixNano())) }, + } + httpMaskBufPool = sync.Pool{ + New: func() any { + b := make([]byte, 0, 1024) + return &b + }, + } +) + +func trimPortForHost(host string) string { + if host == "" { + return host + } + h, _, err := net.SplitHostPort(host) + if err == nil && h != "" { + return h + } + return host +} + +func appendCommonHeaders(buf []byte, host string, r *rand.Rand) []byte { + ua := httpMaskUserAgents[r.Intn(len(httpMaskUserAgents))] + accept := httpMaskAccepts[r.Intn(len(httpMaskAccepts))] + lang := httpMaskAcceptLanguages[r.Intn(len(httpMaskAcceptLanguages))] + enc := httpMaskAcceptEncodings[r.Intn(len(httpMaskAcceptEncodings))] + + buf = append(buf, "Host: "...) + buf = append(buf, host...) + buf = append(buf, "\r\nUser-Agent: "...) + buf = append(buf, ua...) + buf = append(buf, "\r\nAccept: "...) + buf = append(buf, accept...) + buf = append(buf, "\r\nAccept-Language: "...) + buf = append(buf, lang...) + buf = append(buf, "\r\nAccept-Encoding: "...) + buf = append(buf, enc...) + buf = append(buf, "\r\nConnection: keep-alive\r\n"...) + buf = append(buf, "Cache-Control: no-cache\r\nPragma: no-cache\r\n"...) + return buf +} + +// WriteHTTPMaskHeader writes an HTTP/1.x request header as a mask, according to strategy. +// Supported strategies: ""/"random", "post", "websocket". +func WriteHTTPMaskHeader(w io.Writer, host string, strategy string) error { + switch normalizeHTTPMaskStrategy(strategy) { + case "random": + return httpmask.WriteRandomRequestHeader(w, host) + case "post": + return writeHTTPMaskPOST(w, host) + case "websocket": + return writeHTTPMaskWebSocket(w, host) + default: + return fmt.Errorf("unsupported http-mask-strategy: %s", strategy) + } +} + +func writeHTTPMaskPOST(w io.Writer, host string) error { + r := httpMaskRngPool.Get().(*rand.Rand) + defer httpMaskRngPool.Put(r) + + path := httpMaskPaths[r.Intn(len(httpMaskPaths))] + ctype := httpMaskContentTypes[r.Intn(len(httpMaskContentTypes))] + + bufPtr := httpMaskBufPool.Get().(*[]byte) + buf := *bufPtr + buf = buf[:0] + defer func() { + if cap(buf) <= 4096 { + *bufPtr = buf + httpMaskBufPool.Put(bufPtr) + } + }() + + const minCL = int64(4 * 1024) + const maxCL = int64(10 * 1024 * 1024) + contentLength := minCL + r.Int63n(maxCL-minCL+1) + + buf = append(buf, "POST "...) + buf = append(buf, path...) + buf = append(buf, " HTTP/1.1\r\n"...) + buf = appendCommonHeaders(buf, host, r) + buf = append(buf, "Content-Type: "...) + buf = append(buf, ctype...) + buf = append(buf, "\r\nContent-Length: "...) + buf = strconv.AppendInt(buf, contentLength, 10) + buf = append(buf, "\r\n\r\n"...) + + _, err := w.Write(buf) + return err +} + +func writeHTTPMaskWebSocket(w io.Writer, host string) error { + r := httpMaskRngPool.Get().(*rand.Rand) + defer httpMaskRngPool.Put(r) + + path := httpMaskPaths[r.Intn(len(httpMaskPaths))] + + bufPtr := httpMaskBufPool.Get().(*[]byte) + buf := *bufPtr + buf = buf[:0] + defer func() { + if cap(buf) <= 4096 { + *bufPtr = buf + httpMaskBufPool.Put(bufPtr) + } + }() + + hostNoPort := trimPortForHost(host) + var keyBytes [16]byte + for i := 0; i < len(keyBytes); i++ { + keyBytes[i] = byte(r.Intn(256)) + } + var wsKey [24]byte + base64.StdEncoding.Encode(wsKey[:], keyBytes[:]) + + buf = append(buf, "GET "...) + buf = append(buf, path...) + buf = append(buf, " HTTP/1.1\r\n"...) + buf = appendCommonHeaders(buf, host, r) + buf = append(buf, "Upgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: "...) + buf = append(buf, wsKey[:]...) + buf = append(buf, "\r\nOrigin: https://"...) + buf = append(buf, hostNoPort...) + buf = append(buf, "\r\n\r\n"...) + + _, err := w.Write(buf) + return err +} diff --git a/transport/sudoku/obfs_writer.go b/transport/sudoku/obfs_writer.go new file mode 100644 index 00000000..f9803591 --- /dev/null +++ b/transport/sudoku/obfs_writer.go @@ -0,0 +1,113 @@ +package sudoku + +import ( + crypto_rand "crypto/rand" + "encoding/binary" + "math/rand" + "net" + + "github.com/saba-futai/sudoku/pkg/obfs/sudoku" +) + +// perm4 matches github.com/saba-futai/sudoku/pkg/obfs/sudoku perm4. +var perm4 = [24][4]byte{ + {0, 1, 2, 3}, + {0, 1, 3, 2}, + {0, 2, 1, 3}, + {0, 2, 3, 1}, + {0, 3, 1, 2}, + {0, 3, 2, 1}, + {1, 0, 2, 3}, + {1, 0, 3, 2}, + {1, 2, 0, 3}, + {1, 2, 3, 0}, + {1, 3, 0, 2}, + {1, 3, 2, 0}, + {2, 0, 1, 3}, + {2, 0, 3, 1}, + {2, 1, 0, 3}, + {2, 1, 3, 0}, + {2, 3, 0, 1}, + {2, 3, 1, 0}, + {3, 0, 1, 2}, + {3, 0, 2, 1}, + {3, 1, 0, 2}, + {3, 1, 2, 0}, + {3, 2, 0, 1}, + {3, 2, 1, 0}, +} + +type sudokuObfsWriter struct { + conn net.Conn + table *sudoku.Table + rng *rand.Rand + paddingRate float32 + + outBuf []byte + pads []byte + padLen int +} + +func newSudokuObfsWriter(conn net.Conn, table *sudoku.Table, pMin, pMax int) *sudokuObfsWriter { + var seedBytes [8]byte + if _, err := crypto_rand.Read(seedBytes[:]); err != nil { + binary.BigEndian.PutUint64(seedBytes[:], uint64(rand.Int63())) + } + seed := int64(binary.BigEndian.Uint64(seedBytes[:])) + localRng := rand.New(rand.NewSource(seed)) + + min := float32(pMin) / 100.0 + span := float32(pMax-pMin) / 100.0 + rate := min + localRng.Float32()*span + + w := &sudokuObfsWriter{ + conn: conn, + table: table, + rng: localRng, + paddingRate: rate, + } + w.pads = table.PaddingPool + w.padLen = len(w.pads) + return w +} + +func (w *sudokuObfsWriter) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + + // Worst-case: 4 hints + up to 6 paddings per input byte. + needed := len(p)*10 + 1 + if cap(w.outBuf) < needed { + w.outBuf = make([]byte, 0, needed) + } + out := w.outBuf[:0] + + pads := w.pads + padLen := w.padLen + + for _, b := range p { + if padLen > 0 && w.rng.Float32() < w.paddingRate { + out = append(out, pads[w.rng.Intn(padLen)]) + } + + puzzles := w.table.EncodeTable[b] + puzzle := puzzles[w.rng.Intn(len(puzzles))] + + perm := perm4[w.rng.Intn(len(perm4))] + for _, idx := range perm { + if padLen > 0 && w.rng.Float32() < w.paddingRate { + out = append(out, pads[w.rng.Intn(padLen)]) + } + out = append(out, puzzle[idx]) + } + } + + if padLen > 0 && w.rng.Float32() < w.paddingRate { + out = append(out, pads[w.rng.Intn(padLen)]) + } + + w.outBuf = out + _, err := w.conn.Write(out) + return len(p), err +} diff --git a/transport/sudoku/table_probe.go b/transport/sudoku/table_probe.go new file mode 100644 index 00000000..f12c1722 --- /dev/null +++ b/transport/sudoku/table_probe.go @@ -0,0 +1,152 @@ +package sudoku + +import ( + "bufio" + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "time" + + "github.com/saba-futai/sudoku/apis" + "github.com/saba-futai/sudoku/pkg/crypto" + "github.com/saba-futai/sudoku/pkg/obfs/sudoku" +) + +func tableCandidates(cfg *apis.ProtocolConfig) []*sudoku.Table { + if cfg == nil { + return nil + } + if len(cfg.Tables) > 0 { + return cfg.Tables + } + if cfg.Table != nil { + return []*sudoku.Table{cfg.Table} + } + return nil +} + +func pickClientTable(cfg *apis.ProtocolConfig) (*sudoku.Table, byte, error) { + candidates := tableCandidates(cfg) + if len(candidates) == 0 { + return nil, 0, fmt.Errorf("no table configured") + } + if len(candidates) == 1 { + return candidates[0], 0, nil + } + idx := int(randomByte()) % len(candidates) + return candidates[idx], byte(idx), nil +} + +type readOnlyConn struct { + *bytes.Reader +} + +func (c *readOnlyConn) Write([]byte) (int, error) { return 0, io.ErrClosedPipe } +func (c *readOnlyConn) Close() error { return nil } +func (c *readOnlyConn) LocalAddr() net.Addr { return nil } +func (c *readOnlyConn) RemoteAddr() net.Addr { return nil } +func (c *readOnlyConn) SetDeadline(time.Time) error { return nil } +func (c *readOnlyConn) SetReadDeadline(time.Time) error { return nil } +func (c *readOnlyConn) SetWriteDeadline(time.Time) error { return nil } + +func drainBuffered(r *bufio.Reader) ([]byte, error) { + n := r.Buffered() + if n <= 0 { + return nil, nil + } + out := make([]byte, n) + _, err := io.ReadFull(r, out) + return out, err +} + +func probeHandshakeBytes(probe []byte, cfg *apis.ProtocolConfig, table *sudoku.Table) error { + rc := &readOnlyConn{Reader: bytes.NewReader(probe)} + _, obfsConn := buildServerObfsConn(rc, cfg, table, false) + cConn, err := crypto.NewAEADConn(obfsConn, cfg.Key, cfg.AEADMethod) + if err != nil { + return err + } + + var handshakeBuf [16]byte + if _, err := io.ReadFull(cConn, handshakeBuf[:]); err != nil { + return err + } + ts := int64(binary.BigEndian.Uint64(handshakeBuf[:8])) + if absInt64(time.Now().Unix()-ts) > 60 { + return fmt.Errorf("timestamp skew/replay detected") + } + + modeBuf := []byte{0} + if _, err := io.ReadFull(cConn, modeBuf); err != nil { + return err + } + if modeBuf[0] != downlinkMode(cfg) { + return fmt.Errorf("downlink mode mismatch") + } + + return nil +} + +func selectTableByProbe(r *bufio.Reader, cfg *apis.ProtocolConfig, tables []*sudoku.Table) (*sudoku.Table, []byte, error) { + const ( + maxProbeBytes = 64 * 1024 + readChunk = 4 * 1024 + ) + if len(tables) == 0 { + return nil, nil, fmt.Errorf("no table candidates") + } + if len(tables) > 255 { + return nil, nil, fmt.Errorf("too many table candidates: %d", len(tables)) + } + + probe, err := drainBuffered(r) + if err != nil { + return nil, nil, fmt.Errorf("drain buffered bytes failed: %w", err) + } + + tmp := make([]byte, readChunk) + for { + if len(tables) == 1 { + tail, err := drainBuffered(r) + if err != nil { + return nil, nil, fmt.Errorf("drain buffered bytes failed: %w", err) + } + probe = append(probe, tail...) + return tables[0], probe, nil + } + + needMore := false + for _, table := range tables { + err := probeHandshakeBytes(probe, cfg, table) + if err == nil { + tail, err := drainBuffered(r) + if err != nil { + return nil, nil, fmt.Errorf("drain buffered bytes failed: %w", err) + } + probe = append(probe, tail...) + return table, probe, nil + } + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + needMore = true + } + } + + if !needMore { + return nil, probe, fmt.Errorf("handshake table selection failed") + } + if len(probe) >= maxProbeBytes { + return nil, probe, fmt.Errorf("handshake probe exceeded %d bytes", maxProbeBytes) + } + + n, err := r.Read(tmp) + if n > 0 { + probe = append(probe, tmp[:n]...) + } + if err != nil { + return nil, probe, fmt.Errorf("handshake probe read failed: %w", err) + } + } +} diff --git a/transport/sudoku/tables.go b/transport/sudoku/tables.go new file mode 100644 index 00000000..429a4ab3 --- /dev/null +++ b/transport/sudoku/tables.go @@ -0,0 +1,30 @@ +package sudoku + +import ( + "strings" + + "github.com/saba-futai/sudoku/pkg/obfs/sudoku" +) + +// NewTablesWithCustomPatterns builds one or more obfuscation tables from x/v/p custom patterns. +// When customTables is non-empty it overrides customTable (matching upstream Sudoku behavior). +func NewTablesWithCustomPatterns(key string, tableType string, customTable string, customTables []string) ([]*sudoku.Table, error) { + patterns := customTables + if len(patterns) == 0 && strings.TrimSpace(customTable) != "" { + patterns = []string{customTable} + } + if len(patterns) == 0 { + patterns = []string{""} + } + + tables := make([]*sudoku.Table, 0, len(patterns)) + for _, pattern := range patterns { + pattern = strings.TrimSpace(pattern) + t, err := NewTableWithCustom(key, tableType, pattern) + if err != nil { + return nil, err + } + tables = append(tables, t) + } + return tables, nil +}