mirror of
https://github.com/MetaCubeX/mihomo.git
synced 2025-12-19 00:10:06 +08:00
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
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:
parent
bc9db11cb4
commit
a06097c2c4
@ -20,17 +20,19 @@ type Sudoku struct {
|
|||||||
|
|
||||||
type SudokuOption struct {
|
type SudokuOption struct {
|
||||||
BasicOption
|
BasicOption
|
||||||
Name string `proxy:"name"`
|
Name string `proxy:"name"`
|
||||||
Server string `proxy:"server"`
|
Server string `proxy:"server"`
|
||||||
Port int `proxy:"port"`
|
Port int `proxy:"port"`
|
||||||
Key string `proxy:"key"`
|
Key string `proxy:"key"`
|
||||||
AEADMethod string `proxy:"aead-method,omitempty"`
|
AEADMethod string `proxy:"aead-method,omitempty"`
|
||||||
PaddingMin *int `proxy:"padding-min,omitempty"`
|
PaddingMin *int `proxy:"padding-min,omitempty"`
|
||||||
PaddingMax *int `proxy:"padding-max,omitempty"`
|
PaddingMax *int `proxy:"padding-max,omitempty"`
|
||||||
TableType string `proxy:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy"
|
TableType string `proxy:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy"
|
||||||
EnablePureDownlink *bool `proxy:"enable-pure-downlink,omitempty"`
|
EnablePureDownlink *bool `proxy:"enable-pure-downlink,omitempty"`
|
||||||
HTTPMask bool `proxy:"http-mask,omitempty"`
|
HTTPMask bool `proxy:"http-mask,omitempty"`
|
||||||
CustomTable string `proxy:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv
|
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
|
// DialContext implements C.ProxyAdapter
|
||||||
@ -54,7 +56,9 @@ func (s *Sudoku) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Con
|
|||||||
defer done(&err)
|
defer done(&err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c, err = sudoku.ClientHandshake(c, cfg)
|
c, err = sudoku.ClientHandshakeWithOptions(c, cfg, sudoku.ClientHandshakeOptions{
|
||||||
|
HTTPMaskStrategy: s.option.HTTPMaskStrategy,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -97,7 +101,9 @@ func (s *Sudoku) ListenPacketContext(ctx context.Context, metadata *C.Metadata)
|
|||||||
defer done(&err)
|
defer done(&err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c, err = sudoku.ClientHandshake(c, cfg)
|
c, err = sudoku.ClientHandshakeWithOptions(c, cfg, sudoku.ClientHandshakeOptions{
|
||||||
|
HTTPMaskStrategy: s.option.HTTPMaskStrategy,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -185,11 +191,15 @@ func NewSudoku(option SudokuOption) (*Sudoku, error) {
|
|||||||
HandshakeTimeoutSeconds: defaultConf.HandshakeTimeoutSeconds,
|
HandshakeTimeoutSeconds: defaultConf.HandshakeTimeoutSeconds,
|
||||||
DisableHTTPMask: !option.HTTPMask,
|
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 {
|
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 != "" {
|
if option.AEADMethod != "" {
|
||||||
baseConf.AEADMethod = option.AEADMethod
|
baseConf.AEADMethod = option.AEADMethod
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1049,7 +1049,9 @@ proxies: # socks5
|
|||||||
padding-max: 7 # 最大填充字节数
|
padding-max: 7 # 最大填充字节数
|
||||||
table-type: prefer_ascii # 可选值:prefer_ascii、prefer_entropy 前者全ascii映射,后者保证熵值(汉明1)低于3
|
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-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: true # 是否启用http掩码
|
||||||
|
# http-mask-strategy: random # 可选:random(默认)、post、websocket;仅在 http-mask=true 时生效
|
||||||
enable-pure-downlink: false # 是否启用混淆下行,false的情况下能在保证数据安全的前提下极大提升下行速度,与服务端端保持相同(如果此处为false,则要求aead不可为none)
|
enable-pure-downlink: false # 是否启用混淆下行,false的情况下能在保证数据安全的前提下极大提升下行速度,与服务端端保持相同(如果此处为false,则要求aead不可为none)
|
||||||
|
|
||||||
# anytls
|
# anytls
|
||||||
@ -1591,6 +1593,7 @@ listeners:
|
|||||||
padding-max: 15 # 填充最大长度,均不建议过大
|
padding-max: 15 # 填充最大长度,均不建议过大
|
||||||
table-type: prefer_ascii # 可选值:prefer_ascii、prefer_entropy 前者全ascii映射,后者保证熵值(汉明1)低于3
|
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-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
|
handshake-timeout: 5 # optional
|
||||||
enable-pure-downlink: false # 是否启用混淆下行,false的情况下能在保证数据安全的前提下极大提升下行速度,与客户端保持相同(如果此处为false,则要求aead不可为none)
|
enable-pure-downlink: false # 是否启用混淆下行,false的情况下能在保证数据安全的前提下极大提升下行速度,与客户端保持相同(如果此处为false,则要求aead不可为none)
|
||||||
|
|
||||||
|
|||||||
2
go.mod
2
go.mod
@ -43,7 +43,7 @@ require (
|
|||||||
github.com/mroth/weightedrand/v2 v2.1.0
|
github.com/mroth/weightedrand/v2 v2.1.0
|
||||||
github.com/openacid/low v0.1.21
|
github.com/openacid/low v0.1.21
|
||||||
github.com/oschwald/maxminddb-golang v1.12.0 // lastest version compatible with golang1.20
|
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/cors v1.2.1
|
||||||
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a
|
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a
|
||||||
github.com/samber/lo v1.52.0
|
github.com/samber/lo v1.52.0
|
||||||
|
|||||||
4
go.sum
4
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/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 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
|
||||||
github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A=
|
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-d h1:HW/gIyNUFcDchpMN+ZhluM86U/HGkWkkRV+9Km6WZM8=
|
||||||
github.com/saba-futai/sudoku v0.0.2-c/go.mod h1:Rvggsoprp7HQM7bMIZUd1M27bPj8THRsZdY1dGbIAvo=
|
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 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ=
|
||||||
github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI=
|
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=
|
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis=
|
||||||
|
|||||||
@ -9,16 +9,17 @@ import (
|
|||||||
// SudokuServer describes a Sudoku inbound server configuration.
|
// SudokuServer describes a Sudoku inbound server configuration.
|
||||||
// It is internal to the listener layer and mainly used for logging and wiring.
|
// It is internal to the listener layer and mainly used for logging and wiring.
|
||||||
type SudokuServer struct {
|
type SudokuServer struct {
|
||||||
Enable bool `json:"enable"`
|
Enable bool `json:"enable"`
|
||||||
Listen string `json:"listen"`
|
Listen string `json:"listen"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
AEADMethod string `json:"aead-method,omitempty"`
|
AEADMethod string `json:"aead-method,omitempty"`
|
||||||
PaddingMin *int `json:"padding-min,omitempty"`
|
PaddingMin *int `json:"padding-min,omitempty"`
|
||||||
PaddingMax *int `json:"padding-max,omitempty"`
|
PaddingMax *int `json:"padding-max,omitempty"`
|
||||||
TableType string `json:"table-type,omitempty"`
|
TableType string `json:"table-type,omitempty"`
|
||||||
HandshakeTimeoutSecond *int `json:"handshake-timeout,omitempty"`
|
HandshakeTimeoutSecond *int `json:"handshake-timeout,omitempty"`
|
||||||
EnablePureDownlink *bool `json:"enable-pure-downlink,omitempty"`
|
EnablePureDownlink *bool `json:"enable-pure-downlink,omitempty"`
|
||||||
CustomTable string `json:"custom-table,omitempty"`
|
CustomTable string `json:"custom-table,omitempty"`
|
||||||
|
CustomTables []string `json:"custom-tables,omitempty"`
|
||||||
|
|
||||||
// mihomo private extension (not the part of standard Sudoku protocol)
|
// mihomo private extension (not the part of standard Sudoku protocol)
|
||||||
MuxOption sing.MuxOption `json:"mux-option,omitempty"`
|
MuxOption sing.MuxOption `json:"mux-option,omitempty"`
|
||||||
|
|||||||
@ -13,14 +13,15 @@ import (
|
|||||||
|
|
||||||
type SudokuOption struct {
|
type SudokuOption struct {
|
||||||
BaseOption
|
BaseOption
|
||||||
Key string `inbound:"key"`
|
Key string `inbound:"key"`
|
||||||
AEADMethod string `inbound:"aead-method,omitempty"`
|
AEADMethod string `inbound:"aead-method,omitempty"`
|
||||||
PaddingMin *int `inbound:"padding-min,omitempty"`
|
PaddingMin *int `inbound:"padding-min,omitempty"`
|
||||||
PaddingMax *int `inbound:"padding-max,omitempty"`
|
PaddingMax *int `inbound:"padding-max,omitempty"`
|
||||||
TableType string `inbound:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy"
|
TableType string `inbound:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy"
|
||||||
HandshakeTimeoutSecond *int `inbound:"handshake-timeout,omitempty"`
|
HandshakeTimeoutSecond *int `inbound:"handshake-timeout,omitempty"`
|
||||||
EnablePureDownlink *bool `inbound:"enable-pure-downlink,omitempty"`
|
EnablePureDownlink *bool `inbound:"enable-pure-downlink,omitempty"`
|
||||||
CustomTable string `inbound:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv
|
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)
|
// mihomo private extension (not the part of standard Sudoku protocol)
|
||||||
MuxOption MuxOption `inbound:"mux-option,omitempty"`
|
MuxOption MuxOption `inbound:"mux-option,omitempty"`
|
||||||
@ -57,6 +58,7 @@ func NewSudoku(options *SudokuOption) (*Sudoku, error) {
|
|||||||
HandshakeTimeoutSecond: options.HandshakeTimeoutSecond,
|
HandshakeTimeoutSecond: options.HandshakeTimeoutSecond,
|
||||||
EnablePureDownlink: options.EnablePureDownlink,
|
EnablePureDownlink: options.EnablePureDownlink,
|
||||||
CustomTable: options.CustomTable,
|
CustomTable: options.CustomTable,
|
||||||
|
CustomTables: options.CustomTables,
|
||||||
}
|
}
|
||||||
serverConf.MuxOption = options.MuxOption.Build()
|
serverConf.MuxOption = options.MuxOption.Build()
|
||||||
|
|
||||||
|
|||||||
@ -166,7 +166,7 @@ func New(config LC.SudokuServer, tunnel C.Tunnel, additions ...inbound.Addition)
|
|||||||
enablePureDownlink = *config.EnablePureDownlink
|
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 {
|
if err != nil {
|
||||||
_ = l.Close()
|
_ = l.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -180,12 +180,16 @@ func New(config LC.SudokuServer, tunnel C.Tunnel, additions ...inbound.Addition)
|
|||||||
protoConf := sudoku.ProtocolConfig{
|
protoConf := sudoku.ProtocolConfig{
|
||||||
Key: config.Key,
|
Key: config.Key,
|
||||||
AEADMethod: defaultConf.AEADMethod,
|
AEADMethod: defaultConf.AEADMethod,
|
||||||
Table: table,
|
|
||||||
PaddingMin: paddingMin,
|
PaddingMin: paddingMin,
|
||||||
PaddingMax: paddingMax,
|
PaddingMax: paddingMax,
|
||||||
EnablePureDownlink: enablePureDownlink,
|
EnablePureDownlink: enablePureDownlink,
|
||||||
HandshakeTimeoutSeconds: handshakeTimeout,
|
HandshakeTimeoutSeconds: handshakeTimeout,
|
||||||
}
|
}
|
||||||
|
if len(tables) == 1 {
|
||||||
|
protoConf.Table = tables[0]
|
||||||
|
} else {
|
||||||
|
protoConf.Tables = tables
|
||||||
|
}
|
||||||
if config.AEADMethod != "" {
|
if config.AEADMethod != "" {
|
||||||
protoConf.AEADMethod = config.AEADMethod
|
protoConf.AEADMethod = config.AEADMethod
|
||||||
}
|
}
|
||||||
|
|||||||
192
transport/sudoku/features_test.go
Normal file
192
transport/sudoku/features_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,11 +2,13 @@ package sudoku
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/saba-futai/sudoku/apis"
|
"github.com/saba-futai/sudoku/apis"
|
||||||
@ -110,25 +112,35 @@ func downlinkMode(cfg *apis.ProtocolConfig) byte {
|
|||||||
return downlinkModePacked
|
return downlinkModePacked
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildClientObfsConn(raw net.Conn, cfg *apis.ProtocolConfig) net.Conn {
|
func buildClientObfsConn(raw net.Conn, cfg *apis.ProtocolConfig, table *sudoku.Table) net.Conn {
|
||||||
base := sudoku.NewConn(raw, cfg.Table, cfg.PaddingMin, cfg.PaddingMax, false)
|
baseReader := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false)
|
||||||
|
baseWriter := newSudokuObfsWriter(raw, table, cfg.PaddingMin, cfg.PaddingMax)
|
||||||
if cfg.EnablePureDownlink {
|
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{
|
return &directionalConn{
|
||||||
Conn: raw,
|
Conn: raw,
|
||||||
reader: packed,
|
reader: packed,
|
||||||
writer: base,
|
writer: baseWriter,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildServerObfsConn(raw net.Conn, cfg *apis.ProtocolConfig, record bool) (*sudoku.Conn, net.Conn) {
|
func buildServerObfsConn(raw net.Conn, cfg *apis.ProtocolConfig, table *sudoku.Table, record bool) (*sudoku.Conn, net.Conn) {
|
||||||
uplink := sudoku.NewConn(raw, cfg.Table, cfg.PaddingMin, cfg.PaddingMax, record)
|
uplink := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, record)
|
||||||
if cfg.EnablePureDownlink {
|
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{
|
return uplink, &directionalConn{
|
||||||
Conn: raw,
|
Conn: raw,
|
||||||
reader: uplink,
|
reader: uplink,
|
||||||
@ -170,8 +182,19 @@ func ClientAEADSeed(key string) string {
|
|||||||
return key
|
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).
|
// ClientHandshake performs the client-side Sudoku handshake (without sending target address).
|
||||||
func ClientHandshake(rawConn net.Conn, cfg *apis.ProtocolConfig) (net.Conn, error) {
|
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 {
|
if cfg == nil {
|
||||||
return nil, fmt.Errorf("config is required")
|
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 !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)
|
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)
|
cConn, err := crypto.NewAEADConn(obfsConn, ClientAEADSeed(cfg.Key), cfg.AEADMethod)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("setup crypto failed: %w", err)
|
return nil, fmt.Errorf("setup crypto failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handshake := buildHandshakePayload(cfg.Key)
|
handshake := buildHandshakePayload(cfg.Key)
|
||||||
|
if len(tableCandidates(cfg)) > 1 {
|
||||||
|
handshake[15] = tableID
|
||||||
|
}
|
||||||
if _, err := cConn.Write(handshake[:]); err != nil {
|
if _, err := cConn.Write(handshake[:]); err != nil {
|
||||||
cConn.Close()
|
cConn.Close()
|
||||||
return nil, fmt.Errorf("send handshake failed: %w", err)
|
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
|
handshakeTimeout = 5 * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rawConn.SetReadDeadline(time.Now().Add(handshakeTimeout))
|
||||||
|
|
||||||
bufReader := bufio.NewReader(rawConn)
|
bufReader := bufio.NewReader(rawConn)
|
||||||
if !cfg.DisableHTTPMask {
|
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 {
|
if _, err := httpmask.ConsumeHeader(bufReader); err != nil {
|
||||||
return nil, fmt.Errorf("invalid http header: %w", err)
|
return nil, fmt.Errorf("invalid http header: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rawConn.SetReadDeadline(time.Now().Add(handshakeTimeout))
|
selectedTable, preRead, err := selectTableByProbe(bufReader, cfg, tableCandidates(cfg))
|
||||||
bConn := &bufferedConn{
|
if err != nil {
|
||||||
Conn: rawConn,
|
return nil, err
|
||||||
r: bufReader,
|
|
||||||
}
|
}
|
||||||
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)
|
cConn, err := crypto.NewAEADConn(obfsConn, cfg.Key, cfg.AEADMethod)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("crypto setup failed: %w", err)
|
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
|
publicKey = crypto.EncodePoint(pair.Public) // Master Public Key for server
|
||||||
return
|
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())
|
||||||
|
}
|
||||||
|
|||||||
179
transport/sudoku/httpmask_strategy.go
Normal file
179
transport/sudoku/httpmask_strategy.go
Normal 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
|
||||||
|
}
|
||||||
113
transport/sudoku/obfs_writer.go
Normal file
113
transport/sudoku/obfs_writer.go
Normal 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
|
||||||
|
}
|
||||||
152
transport/sudoku/table_probe.go
Normal file
152
transport/sudoku/table_probe.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
transport/sudoku/tables.go
Normal file
30
transport/sudoku/tables.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user