From 904c9f09850a7ce89930862d9758e03eba85e5bc Mon Sep 17 00:00:00 2001 From: saba Date: Fri, 5 Dec 2025 01:20:50 +0800 Subject: [PATCH] update. increase downlink bandwidth --- adapter/outbound/sudoku.go | 55 ++----- go.mod | 2 +- go.sum | 4 +- listener/config/sudoku.go | 1 + listener/inbound/sudoku.go | 16 +- listener/inbound/sudoku_test.go | 14 ++ listener/sudoku/server.go | 5 + transport/sudoku/handshake.go | 134 ++++++++++++++++- transport/sudoku/handshake_test.go | 230 +++++++++++++++++++++++++++++ 9 files changed, 410 insertions(+), 51 deletions(-) create mode 100644 transport/sudoku/handshake_test.go diff --git a/adapter/outbound/sudoku.go b/adapter/outbound/sudoku.go index 44affacc..ef28f3ac 100644 --- a/adapter/outbound/sudoku.go +++ b/adapter/outbound/sudoku.go @@ -2,8 +2,6 @@ package outbound import ( "context" - "crypto/sha256" - "encoding/binary" "fmt" "net" "strconv" @@ -12,7 +10,6 @@ import ( "github.com/saba-futai/sudoku/apis" "github.com/saba-futai/sudoku/pkg/crypto" - "github.com/saba-futai/sudoku/pkg/obfs/httpmask" sudokuobfs "github.com/saba-futai/sudoku/pkg/obfs/sudoku" N "github.com/metacubex/mihomo/common/net" @@ -30,15 +27,16 @@ 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" - HTTPMask bool `proxy:"http-mask,omitempty"` + 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"` } // DialContext implements C.ProxyAdapter @@ -135,25 +133,7 @@ func (s *Sudoku) buildConfig(metadata *C.Metadata) (*apis.ProtocolConfig, error) } func (s *Sudoku) handshakeConn(rawConn net.Conn, cfg *apis.ProtocolConfig) (_ net.Conn, err error) { - if !cfg.DisableHTTPMask { - if err = httpmask.WriteRandomRequestHeader(rawConn, cfg.ServerAddress); err != nil { - return nil, fmt.Errorf("write http mask failed: %w", err) - } - } - - obfsConn := sudokuobfs.NewConn(rawConn, cfg.Table, cfg.PaddingMin, cfg.PaddingMax, false) - cConn, err := crypto.NewAEADConn(obfsConn, cfg.Key, cfg.AEADMethod) - if err != nil { - return nil, fmt.Errorf("setup crypto failed: %w", err) - } - - handshake := buildSudokuHandshakePayload(cfg.Key) - if _, err = cConn.Write(handshake[:]); err != nil { - cConn.Close() - return nil, fmt.Errorf("send handshake failed: %w", err) - } - - return cConn, nil + return sudoku.ClientHandshake(rawConn, cfg) } func (s *Sudoku) streamConn(rawConn net.Conn, cfg *apis.ProtocolConfig) (_ net.Conn, err error) { @@ -218,6 +198,10 @@ func NewSudoku(option SudokuOption) (*Sudoku, error) { if option.PaddingMax == nil && option.PaddingMin != nil && paddingMax < paddingMin { paddingMax = paddingMin } + enablePureDownlink := defaultConf.EnablePureDownlink + if option.EnablePureDownlink != nil { + enablePureDownlink = *option.EnablePureDownlink + } baseConf := apis.ProtocolConfig{ ServerAddress: net.JoinHostPort(option.Server, strconv.Itoa(option.Port)), @@ -226,6 +210,7 @@ func NewSudoku(option SudokuOption) (*Sudoku, error) { Table: table, PaddingMin: paddingMin, PaddingMax: paddingMax, + EnablePureDownlink: enablePureDownlink, HandshakeTimeoutSeconds: defaultConf.HandshakeTimeoutSeconds, DisableHTTPMask: !option.HTTPMask, } @@ -253,11 +238,3 @@ func NewSudoku(option SudokuOption) (*Sudoku, error) { outbound.dialer = option.NewDialer(outbound.DialOptions()) return outbound, nil } - -func buildSudokuHandshakePayload(key string) [16]byte { - var payload [16]byte - binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Unix())) - hash := sha256.Sum256([]byte(key)) - copy(payload[8:], hash[:8]) - return payload -} diff --git a/go.mod b/go.mod index 500aad83..eeac3a67 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.1-i + github.com/saba-futai/sudoku v0.0.2-a 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 37cf8ad7..9fb32c19 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.1-i h1:t6H875LSceXaEEwho84GU9OoLa4ieoBo3v+dxpFf4wc= -github.com/saba-futai/sudoku v0.0.1-i/go.mod h1:FNtEAA44TSMvHI94o1kri/itbjvSMm1qCrbd0e6MTZY= +github.com/saba-futai/sudoku v0.0.2-a h1:6/u4zOAGXXvjqjQbHlzNi4anXRDiVcQ+RwjF6IgChj0= +github.com/saba-futai/sudoku v0.0.2-a/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 79aadf5d..b1ceed08 100644 --- a/listener/config/sudoku.go +++ b/listener/config/sudoku.go @@ -13,6 +13,7 @@ type SudokuServer struct { 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"` } func (s SudokuServer) String() string { diff --git a/listener/inbound/sudoku.go b/listener/inbound/sudoku.go index bc08772a..dd7cce5b 100644 --- a/listener/inbound/sudoku.go +++ b/listener/inbound/sudoku.go @@ -21,6 +21,7 @@ type SudokuOption struct { 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"` } func (o SudokuOption) Equal(config C.InboundConfig) bool { @@ -46,13 +47,14 @@ func NewSudoku(options *SudokuOption) (*Sudoku, error) { defaultConf := apis.DefaultConfig() serverConf := LC.SudokuServer{ - Enable: true, - Listen: base.RawAddress(), - Key: options.Key, - AEADMethod: options.AEADMethod, - PaddingMin: options.PaddingMin, - PaddingMax: options.PaddingMax, - TableType: options.TableType, + Enable: true, + Listen: base.RawAddress(), + Key: options.Key, + AEADMethod: options.AEADMethod, + PaddingMin: options.PaddingMin, + PaddingMax: options.PaddingMax, + TableType: options.TableType, + EnablePureDownlink: options.EnablePureDownlink, } if options.HandshakeTimeoutSecond != nil { serverConf.HandshakeTimeoutSecond = options.HandshakeTimeoutSecond diff --git a/listener/inbound/sudoku_test.go b/listener/inbound/sudoku_test.go index 6d3e35b1..7c9d622a 100644 --- a/listener/inbound/sudoku_test.go +++ b/listener/inbound/sudoku_test.go @@ -89,3 +89,17 @@ func TestInboundSudoku_Padding(t *testing.T) { } testInboundSudoku(t, inboundOptions, outboundOptions) } + +func TestInboundSudoku_PackedDownlink(t *testing.T) { + key := "test_key_packed" + enablePure := false + inboundOptions := inbound.SudokuOption{ + Key: key, + EnablePureDownlink: &enablePure, + } + outboundOptions := outbound.SudokuOption{ + Key: key, + EnablePureDownlink: &enablePure, + } + testInboundSudoku(t, inboundOptions, outboundOptions) +} diff --git a/listener/sudoku/server.go b/listener/sudoku/server.go index f3260eb3..d8e7337c 100644 --- a/listener/sudoku/server.go +++ b/listener/sudoku/server.go @@ -152,6 +152,10 @@ func New(config LC.SudokuServer, tunnel C.Tunnel, additions ...inbound.Addition) if config.PaddingMax == nil && config.PaddingMin != nil && paddingMax < paddingMin { paddingMax = paddingMin } + enablePureDownlink := defaultConf.EnablePureDownlink + if config.EnablePureDownlink != nil { + enablePureDownlink = *config.EnablePureDownlink + } handshakeTimeout := defaultConf.HandshakeTimeoutSeconds if config.HandshakeTimeoutSecond != nil { @@ -164,6 +168,7 @@ func New(config LC.SudokuServer, tunnel C.Tunnel, additions ...inbound.Addition) Table: table, PaddingMin: paddingMin, PaddingMax: paddingMax, + EnablePureDownlink: enablePureDownlink, HandshakeTimeoutSeconds: handshakeTimeout, } if config.AEADMethod != "" { diff --git a/transport/sudoku/handshake.go b/transport/sudoku/handshake.go index 1f822707..05abdf66 100644 --- a/transport/sudoku/handshake.go +++ b/transport/sudoku/handshake.go @@ -2,6 +2,7 @@ package sudoku import ( "bufio" + "crypto/sha256" "encoding/binary" "fmt" "io" @@ -55,6 +56,37 @@ func (p *preBufferedConn) Read(b []byte) (int, error) { return p.Conn.Read(b) } +type directionalConn struct { + net.Conn + reader io.Reader + writer io.Writer + closers []func() error +} + +func (c *directionalConn) Read(p []byte) (int, error) { + return c.reader.Read(p) +} + +func (c *directionalConn) Write(p []byte) (int, error) { + return c.writer.Write(p) +} + +func (c *directionalConn) Close() error { + var firstErr error + for _, fn := range c.closers { + if fn == nil { + continue + } + if err := fn(); err != nil && firstErr == nil { + firstErr = err + } + } + if err := c.Conn.Close(); err != nil && firstErr == nil { + firstErr = err + } + return firstErr +} + func absInt64(v int64) int64 { if v < 0 { return -v @@ -62,6 +94,94 @@ func absInt64(v int64) int64 { return v } +const ( + downlinkModePure byte = 0x01 + downlinkModePacked byte = 0x02 +) + +func downlinkMode(cfg *apis.ProtocolConfig) byte { + if cfg.EnablePureDownlink { + return downlinkModePure + } + return downlinkModePacked +} + +func clientAEADSeed(key string) string { + if recovered, err := crypto.RecoverPublicKey(key); err == nil { + return crypto.EncodePoint(recovered) + } + return key +} + +func buildClientObfsConn(raw net.Conn, cfg *apis.ProtocolConfig) net.Conn { + base := sudoku.NewConn(raw, cfg.Table, cfg.PaddingMin, cfg.PaddingMax, false) + if cfg.EnablePureDownlink { + return base + } + packed := sudoku.NewPackedConn(raw, cfg.Table, cfg.PaddingMin, cfg.PaddingMax) + return &directionalConn{ + Conn: raw, + reader: packed, + writer: base, + } +} + +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) + if cfg.EnablePureDownlink { + return uplink, uplink + } + packed := sudoku.NewPackedConn(raw, cfg.Table, cfg.PaddingMin, cfg.PaddingMax) + return uplink, &directionalConn{ + Conn: raw, + reader: uplink, + writer: packed, + closers: []func() error{packed.Flush}, + } +} + +func buildHandshakePayload(key string) [16]byte { + var payload [16]byte + binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Unix())) + hash := sha256.Sum256([]byte(key)) + copy(payload[8:], hash[:8]) + return payload +} + +// ClientHandshake performs the client-side Sudoku handshake (without sending target address). +func ClientHandshake(rawConn net.Conn, cfg *apis.ProtocolConfig) (net.Conn, error) { + if cfg == nil { + return nil, fmt.Errorf("config is required") + } + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + if !cfg.DisableHTTPMask { + if err := httpmask.WriteRandomRequestHeader(rawConn, cfg.ServerAddress); err != nil { + return nil, fmt.Errorf("write http mask failed: %w", err) + } + } + + obfsConn := buildClientObfsConn(rawConn, cfg) + 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 _, err := cConn.Write(handshake[:]); err != nil { + cConn.Close() + return nil, fmt.Errorf("send handshake failed: %w", err) + } + if _, err := cConn.Write([]byte{downlinkMode(cfg)}); err != nil { + cConn.Close() + return nil, fmt.Errorf("send downlink mode failed: %w", err) + } + + return cConn, nil +} + // ServerHandshake performs Sudoku server-side handshake and detects UoT preface. func ServerHandshake(rawConn net.Conn, cfg *apis.ProtocolConfig) (*ServerSession, error) { if cfg == nil { @@ -90,8 +210,8 @@ func ServerHandshake(rawConn net.Conn, cfg *apis.ProtocolConfig) (*ServerSession Conn: rawConn, r: bufReader, } - sConn := sudoku.NewConn(bConn, cfg.Table, cfg.PaddingMin, cfg.PaddingMax, true) - cConn, err := crypto.NewAEADConn(sConn, cfg.Key, cfg.AEADMethod) + sConn, obfsConn := buildServerObfsConn(bConn, cfg, true) + cConn, err := crypto.NewAEADConn(obfsConn, cfg.Key, cfg.AEADMethod) if err != nil { return nil, fmt.Errorf("crypto setup failed: %w", err) } @@ -110,6 +230,16 @@ func ServerHandshake(rawConn net.Conn, cfg *apis.ProtocolConfig) (*ServerSession sConn.StopRecording() + modeBuf := []byte{0} + if _, err := io.ReadFull(cConn, modeBuf); err != nil { + cConn.Close() + return nil, fmt.Errorf("read downlink mode failed: %w", err) + } + if modeBuf[0] != downlinkMode(cfg) { + cConn.Close() + return nil, fmt.Errorf("downlink mode mismatch: client=%d server=%d", modeBuf[0], downlinkMode(cfg)) + } + firstByte := make([]byte, 1) if _, err := io.ReadFull(cConn, firstByte); err != nil { cConn.Close() diff --git a/transport/sudoku/handshake_test.go b/transport/sudoku/handshake_test.go new file mode 100644 index 00000000..cfe5c75e --- /dev/null +++ b/transport/sudoku/handshake_test.go @@ -0,0 +1,230 @@ +package sudoku + +import ( + "bytes" + "fmt" + "io" + "net" + "sync" + "testing" + "time" + + "github.com/saba-futai/sudoku/apis" + sudokuobfs "github.com/saba-futai/sudoku/pkg/obfs/sudoku" +) + +func TestPackedConnRoundTrip_WithPadding(t *testing.T) { + payload := []byte{0x3a, 0x1f, 0x71, 0x00, 0xff, 0x10, 0x22} + tableTypes := []string{"prefer_ascii", "prefer_entropy"} + + for _, tt := range tableTypes { + t.Run(tt, func(t *testing.T) { + serverConn, clientConn := net.Pipe() + defer serverConn.Close() + defer clientConn.Close() + + table := sudokuobfs.NewTable("roundtrip-seed", tt) + writer := sudokuobfs.NewPackedConn(serverConn, table, 30, 80) + reader := sudokuobfs.NewPackedConn(clientConn, table, 30, 80) + + writeErr := make(chan error, 1) + go func() { + if _, err := writer.Write(payload); err != nil { + writeErr <- err + return + } + if err := writer.Flush(); err != nil { + writeErr <- err + return + } + writeErr <- serverConn.Close() + }() + + done := make(chan struct{}) + var got []byte + var readErr error + go func() { + got, readErr = io.ReadAll(reader) + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("read timeout") + } + + if err := <-writeErr; err != nil && err != io.EOF { + t.Fatalf("write side error: %v", err) + } + if readErr != nil && readErr != io.EOF { + t.Fatalf("read side error: %v", readErr) + } + if !bytes.Equal(got, payload) { + t.Fatalf("payload mismatch, want %x got %x", payload, got) + } + }) + } +} + +func newPackedConfig(table *sudokuobfs.Table) *apis.ProtocolConfig { + cfg := apis.DefaultConfig() + cfg.Key = "sudoku-test-key" + cfg.Table = table + cfg.PaddingMin = 10 + cfg.PaddingMax = 30 + cfg.EnablePureDownlink = false + cfg.ServerAddress = "example.com:443" + cfg.DisableHTTPMask = true + return cfg +} + +func TestPackedDownlinkSoak(t *testing.T) { + const sessions = 16 + + table := sudokuobfs.NewTable("soak-seed", "prefer_ascii") + cfg := newPackedConfig(table) + + var wg sync.WaitGroup + errCh := make(chan error, sessions*2) + + for i := 0; i < sessions; i++ { + wg.Add(2) + go func(id int) { + defer wg.Done() + runPackedTCPSession(id, cfg, errCh) + }(i) + go func(id int) { + defer wg.Done() + runPackedUoTSession(id, cfg, errCh) + }(i) + } + + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("soak test timeout") + } + + close(errCh) + for err := range errCh { + t.Fatalf("soak error: %v", err) + } +} + +func runPackedTCPSession(id int, cfg *apis.ProtocolConfig, errCh chan<- error) { + serverConn, clientConn := net.Pipe() + target := fmt.Sprintf("1.1.1.%d:80", (id%200)+1) + payload := []byte{0x42, byte(id)} + + // Server side + go func() { + session, err := ServerHandshake(serverConn, cfg) + if err != nil { + errCh <- fmt.Errorf("server handshake tcp: %w", err) + return + } + defer session.Conn.Close() + + if session.Type != SessionTypeTCP { + errCh <- fmt.Errorf("unexpected session type: %v", session.Type) + return + } + if session.Target != target { + errCh <- fmt.Errorf("target mismatch want %s got %s", target, session.Target) + return + } + if _, err := session.Conn.Write(payload); err != nil { + errCh <- fmt.Errorf("server write: %w", err) + return + } + }() + + // Client side + clientCfg := *cfg + cConn, err := ClientHandshake(clientConn, &clientCfg) + if err != nil { + errCh <- fmt.Errorf("client handshake tcp: %w", err) + return + } + defer cConn.Close() + + addrBuf, err := EncodeAddress(target) + if err != nil { + errCh <- fmt.Errorf("encode address: %w", err) + return + } + if _, err := cConn.Write(addrBuf); err != nil { + errCh <- fmt.Errorf("client send addr: %w", err) + return + } + + buf := make([]byte, len(payload)) + if _, err := io.ReadFull(cConn, buf); err != nil { + errCh <- fmt.Errorf("client read: %w", err) + return + } + if !bytes.Equal(buf, payload) { + errCh <- fmt.Errorf("payload mismatch want %x got %x", payload, buf) + return + } +} + +func runPackedUoTSession(id int, cfg *apis.ProtocolConfig, errCh chan<- error) { + serverConn, clientConn := net.Pipe() + target := "8.8.8.8:53" + payload := []byte{0xaa, byte(id)} + + // Server side + go func() { + session, err := ServerHandshake(serverConn, cfg) + if err != nil { + errCh <- fmt.Errorf("server handshake uot: %w", err) + return + } + defer session.Conn.Close() + + if session.Type != SessionTypeUoT { + errCh <- fmt.Errorf("unexpected session type: %v", session.Type) + return + } + if err := WriteDatagram(session.Conn, target, payload); err != nil { + errCh <- fmt.Errorf("server write datagram: %w", err) + return + } + }() + + // Client side + clientCfg := *cfg + cConn, err := ClientHandshake(clientConn, &clientCfg) + if err != nil { + errCh <- fmt.Errorf("client handshake uot: %w", err) + return + } + defer cConn.Close() + + if err := WritePreface(cConn); err != nil { + errCh <- fmt.Errorf("client write preface: %w", err) + return + } + + addr, data, err := ReadDatagram(cConn) + if err != nil { + errCh <- fmt.Errorf("client read datagram: %w", err) + return + } + if addr != target { + errCh <- fmt.Errorf("uot target mismatch want %s got %s", target, addr) + return + } + if !bytes.Equal(data, payload) { + errCh <- fmt.Errorf("uot payload mismatch want %x got %x", payload, data) + return + } +}