chore: add xvp rotation andd new header generation strategy for sudoku (#2437)
Some checks are pending
Test / test (1.20, macos-15-intel) (push) Waiting to run
Test / test (1.20, macos-latest) (push) Waiting to run
Test / test (1.20, ubuntu-24.04-arm) (push) Waiting to run
Test / test (1.20, ubuntu-latest) (push) Waiting to run
Test / test (1.20, windows-latest) (push) Waiting to run
Test / test (1.21, macos-15-intel) (push) Waiting to run
Test / test (1.21, macos-latest) (push) Waiting to run
Test / test (1.21, ubuntu-24.04-arm) (push) Waiting to run
Test / test (1.21, ubuntu-latest) (push) Waiting to run
Test / test (1.21, windows-latest) (push) Waiting to run
Test / test (1.22, macos-15-intel) (push) Waiting to run
Test / test (1.22, macos-latest) (push) Waiting to run
Test / test (1.22, ubuntu-24.04-arm) (push) Waiting to run
Test / test (1.22, ubuntu-latest) (push) Waiting to run
Test / test (1.22, windows-latest) (push) Waiting to run
Test / test (1.23, macos-15-intel) (push) Waiting to run
Test / test (1.23, macos-latest) (push) Waiting to run
Test / test (1.23, ubuntu-24.04-arm) (push) Waiting to run
Test / test (1.23, ubuntu-latest) (push) Waiting to run
Test / test (1.23, windows-latest) (push) Waiting to run
Test / test (1.24, macos-15-intel) (push) Waiting to run
Test / test (1.24, macos-latest) (push) Waiting to run
Test / test (1.24, ubuntu-24.04-arm) (push) Waiting to run
Test / test (1.24, ubuntu-latest) (push) Waiting to run
Test / test (1.24, windows-latest) (push) Waiting to run
Test / test (1.25, macos-15-intel) (push) Waiting to run
Test / test (1.25, macos-latest) (push) Waiting to run
Test / test (1.25, ubuntu-24.04-arm) (push) Waiting to run
Test / test (1.25, ubuntu-latest) (push) Waiting to run
Test / test (1.25, windows-latest) (push) Waiting to run
Trigger CMFA Update / trigger-CMFA-update (push) Waiting to run

This commit is contained in:
saba-futai 2025-12-16 18:39:39 +08:00 committed by GitHub
parent bc9db11cb4
commit a06097c2c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 798 additions and 56 deletions

View File

@ -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
}

View File

@ -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)

2
go.mod
View File

@ -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

4
go.sum
View File

@ -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=

View File

@ -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"`

View File

@ -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()

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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())
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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
}