├── go.mod ├── pkg ├── obfs │ ├── sudoku │ │ ├── conn_edge_test.go │ │ ├── grid.go │ │ ├── fuzz_roundtrip_test.go │ │ ├── table_set.go │ │ ├── benchmark_test.go │ │ ├── table.go │ │ ├── custom_layout_test.go │ │ ├── conn.go │ │ └── layout.go │ └── httpmask │ │ ├── masker_extra_test.go │ │ ├── masker_test.go │ │ └── tunnel_test.go ├── crypto │ ├── aead_conn_test.go │ ├── key_test.go │ ├── aead.go │ └── ed25519.go ├── dnsutil │ ├── resolver_test.go │ └── resolver.go └── geodata │ └── manager.go ├── internal ├── config │ ├── save.go │ ├── init_config.go │ ├── init_config_test.go │ ├── config.go │ ├── shortlink_test.go │ └── shortlink.go ├── app │ ├── pipe.go │ ├── server.go │ ├── client_test.go │ └── setup.go ├── handler │ ├── fallback.go │ └── fallback_test.go ├── protocol │ ├── address_test.go │ └── address.go └── tunnel │ ├── obfs_conn.go │ ├── tunnel_test.go │ ├── buffered_conn_test.go │ ├── uot.go │ └── dialer.go ├── .github ├── workflows │ ├── pr.yml │ └── release.yml └── ISSUE_TEMPLATE │ └── bug_report.md ├── apis ├── client_test.go ├── doc.go ├── uot.go ├── config_test.go ├── obfs.go ├── errors.go ├── httpmask_tunnel_server.go ├── README.md ├── config.go └── client.go ├── LICENSE ├── go.sum ├── configs └── config.json ├── Makefile ├── tests ├── httpmask_tunnel_benchmark_test.go ├── httpmask_switch_test.go ├── api_uot_test.go └── multi_table_rotation_test.go ├── assets ├── logo-brutal.svg └── logo-cute.svg ├── doc ├── getting-started.zh.md ├── CHANGELOG.zh.md └── getting-started.en.md ├── cmd └── sudoku-tunnel │ └── main.go └── README.zh_CN.md /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/saba-futai/sudoku 2 | 3 | go 1.24.7 4 | 5 | require ( 6 | filippo.io/edwards25519 v1.1.0 7 | golang.org/x/crypto v0.45.0 8 | gopkg.in/yaml.v3 v3.0.1 9 | ) 10 | 11 | require golang.org/x/sys v0.38.0 // indirect 12 | -------------------------------------------------------------------------------- /pkg/obfs/sudoku/conn_edge_test.go: -------------------------------------------------------------------------------- 1 | package sudoku 2 | 3 | import ( 4 | "net" 5 | "testing" 6 | ) 7 | 8 | func TestConnWrite_Empty(t *testing.T) { 9 | c1, c2 := net.Pipe() 10 | defer c1.Close() 11 | defer c2.Close() 12 | 13 | table := NewTable("edge-key", "prefer_entropy") 14 | conn := NewConn(c1, table, 0, 0, false) 15 | if n, err := conn.Write(nil); err != nil || n != 0 { 16 | t.Fatalf("Write(nil) = (%d, %v), want (0, nil)", n, err) 17 | } 18 | if n, err := conn.Write([]byte{}); err != nil || n != 0 { 19 | t.Fatalf("Write(empty) = (%d, %v), want (0, nil)", n, err) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /internal/config/save.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | // Save writes the config to disk with indentation. 10 | func Save(path string, cfg *Config) error { 11 | if cfg == nil { 12 | return nil 13 | } 14 | 15 | dir := filepath.Dir(path) 16 | if dir != "." { 17 | if err := os.MkdirAll(dir, 0o755); err != nil { 18 | return err 19 | } 20 | } 21 | 22 | f, err := os.Create(path) 23 | if err != nil { 24 | return err 25 | } 26 | defer f.Close() 27 | 28 | enc := json.NewEncoder(f) 29 | enc.SetIndent("", " ") 30 | return enc.Encode(cfg) 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: pr-ci 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | test: 14 | name: Race & Integration Tests 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 20 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup Go 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version-file: go.mod 25 | cache: true 26 | 27 | - name: Run full test suite with race detector 28 | run: go test ./... -race -count=1 29 | -------------------------------------------------------------------------------- /apis/client_test.go: -------------------------------------------------------------------------------- 1 | package apis 2 | 3 | import ( 4 | "crypto/sha256" 5 | "testing" 6 | ) 7 | 8 | func TestBuildHandshakePayload(t *testing.T) { 9 | key := "handshake-key" 10 | p := buildHandshakePayload(key) 11 | 12 | if len(p) != 16 { 13 | t.Fatalf("unexpected length %d", len(p)) 14 | } 15 | allZero := true 16 | for i := 0; i < 8; i++ { 17 | if p[i] != 0 { 18 | allZero = false 19 | break 20 | } 21 | } 22 | if allZero { 23 | t.Fatalf("timestamp appears zero") 24 | } 25 | 26 | hash := sha256.Sum256([]byte(key)) 27 | for i := 0; i < 8; i++ { 28 | if p[8+i] != hash[i] { 29 | t.Fatalf("hash segment mismatch at %d", i) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/app/pipe.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "io" 5 | "net" 6 | "sync" 7 | ) 8 | 9 | // copyBufferPool reuses buffers for bidirectional piping to reduce GC churn. 10 | var copyBufferPool = sync.Pool{ 11 | New: func() interface{} { 12 | return make([]byte, 32*1024) 13 | }, 14 | } 15 | 16 | func pipeConn(a, b net.Conn) { 17 | var once sync.Once 18 | 19 | closeBoth := func() { 20 | _ = a.Close() 21 | _ = b.Close() 22 | } 23 | 24 | go func() { 25 | copyOneWay(a, b) 26 | once.Do(closeBoth) 27 | }() 28 | 29 | copyOneWay(b, a) 30 | once.Do(closeBoth) 31 | } 32 | 33 | func copyOneWay(dst io.Writer, src io.Reader) { 34 | buf := copyBufferPool.Get().([]byte) 35 | defer copyBufferPool.Put(buf) 36 | _, _ = io.CopyBuffer(dst, src, buf) 37 | } 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | **Log** 17 | Briefly paste log here with necessary lines. 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | 22 | **Server:** 23 | - OS: [e.g. Ubuntu] 24 | - Core: [eg. sudoku official / mihomo / singbox] 25 | - Version [e.g. 0.0.3] 26 | 27 | **Client:** 28 | - Core: [eg. mihomo] 29 | - OS: [e.g. iOS8.1] 30 | - Version [e.g. 1.19] 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2025 by ふたい 2 | 3 | This program is free software: you can redistribute it and/or modify 4 | it under the terms of the GNU General Public License as published by 5 | the Free Software Foundation, either version 3 of the License, or 6 | (at your option) any later version. 7 | 8 | This program is distributed in the hope that it will be useful, 9 | but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | GNU General Public License for more details. 12 | 13 | You should have received a copy of the GNU General Public License 14 | along with this program. If not, see . 15 | 16 | In addition, no derivative work may use the name or imply association 17 | with this application without prior consent. -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= 4 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 5 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 6 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /configs/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "server/client", 3 | "local_port": 1080, 4 | "server_address": "0.0.0.0:8080", 5 | "fallback_address": "127.0.0.1:80", 6 | "key": "用./sudoku -keygen 生成,具体见README的运行部分", 7 | "aead": "chacha20-poly1305 / aes-128-gcm / none", 8 | "suspicious_action": "fallback / silent", 9 | "padding_min": 5, 10 | "padding_max": 15, 11 | "custom_table": "xpxvvpvv", 12 | "custom_tables": [ 13 | "xpxvvpvv", 14 | "vxpvxvvp" 15 | ], 16 | "ascii": "prefer_entropy", 17 | "enable_pure_downlink": true, 18 | "disable_http_mask": false, 19 | "http_mask_mode": "legacy", 20 | "http_mask_tls": false, 21 | "rule_urls": [ 22 | "https://gh-proxy.org/https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Clash/China/China.list", 23 | "https://gh-proxy.org/https://raw.githubusercontent.com/fernvenue/chn-cidr-list/master/ipv4.yaml", 24 | "global","direct" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /pkg/obfs/sudoku/grid.go: -------------------------------------------------------------------------------- 1 | // pkg/obfs/sudoku/grid.go 2 | package sudoku 3 | 4 | // Grid represents a 4x4 sudoku grid 5 | type Grid [16]uint8 6 | 7 | // GenerateAllGrids generates all valid 4x4 Sudoku grids 8 | func GenerateAllGrids() []Grid { 9 | var grids []Grid 10 | var g Grid 11 | var backtrack func(int) 12 | 13 | backtrack = func(idx int) { 14 | if idx == 16 { 15 | grids = append(grids, g) 16 | return 17 | } 18 | row, col := idx/4, idx%4 19 | br, bc := (row/2)*2, (col/2)*2 20 | for num := uint8(1); num <= 4; num++ { 21 | valid := true 22 | for i := 0; i < 4; i++ { 23 | if g[row*4+i] == num || g[i*4+col] == num { 24 | valid = false 25 | break 26 | } 27 | } 28 | if valid { 29 | for r := 0; r < 2; r++ { 30 | for c := 0; c < 2; c++ { 31 | if g[(br+r)*4+(bc+c)] == num { 32 | valid = false 33 | break 34 | } 35 | } 36 | } 37 | } 38 | if valid { 39 | g[idx] = num 40 | backtrack(idx + 1) 41 | g[idx] = 0 42 | } 43 | } 44 | } 45 | backtrack(0) 46 | return grids 47 | } 48 | -------------------------------------------------------------------------------- /pkg/obfs/sudoku/fuzz_roundtrip_test.go: -------------------------------------------------------------------------------- 1 | package sudoku 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net" 7 | "testing" 8 | ) 9 | 10 | func FuzzConnRoundTrip(f *testing.F) { 11 | table := NewTable("fuzz-key", "prefer_entropy") 12 | f.Add([]byte("hello")) 13 | f.Add([]byte{}) 14 | 15 | f.Fuzz(func(t *testing.T, data []byte) { 16 | // Keep per-case runtime bounded. 17 | if len(data) > 256 { 18 | data = data[:256] 19 | } 20 | 21 | c1, c2 := net.Pipe() 22 | defer c1.Close() 23 | defer c2.Close() 24 | 25 | writer := NewConn(c1, table, 0, 0, false) 26 | reader := NewConn(c2, table, 0, 0, false) 27 | 28 | writeErr := make(chan error, 1) 29 | go func() { 30 | _, err := writer.Write(data) 31 | _ = c1.Close() 32 | writeErr <- err 33 | }() 34 | 35 | got := make([]byte, len(data)) 36 | if _, err := io.ReadFull(reader, got); err != nil { 37 | t.Fatalf("read failed: %v", err) 38 | } 39 | if err := <-writeErr; err != nil { 40 | t.Fatalf("write failed: %v", err) 41 | } 42 | if !bytes.Equal(got, data) { 43 | t.Fatalf("roundtrip mismatch") 44 | } 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /pkg/obfs/sudoku/table_set.go: -------------------------------------------------------------------------------- 1 | package sudoku 2 | 3 | import "fmt" 4 | 5 | // TableSet is a small helper for managing multiple Sudoku tables (e.g. for per-connection rotation). 6 | // It is intentionally decoupled from the tunnel/app layers. 7 | type TableSet struct { 8 | Tables []*Table 9 | } 10 | 11 | // NewTableSet builds one or more tables from key/mode and a list of custom X/P/V patterns. 12 | // If patterns is empty, it builds a single default table (customPattern=""). 13 | func NewTableSet(key string, mode string, patterns []string) (*TableSet, error) { 14 | if len(patterns) == 0 { 15 | t, err := NewTableWithCustom(key, mode, "") 16 | if err != nil { 17 | return nil, err 18 | } 19 | return &TableSet{Tables: []*Table{t}}, nil 20 | } 21 | 22 | tables := make([]*Table, 0, len(patterns)) 23 | for i, pattern := range patterns { 24 | t, err := NewTableWithCustom(key, mode, pattern) 25 | if err != nil { 26 | return nil, fmt.Errorf("build table[%d] (%q): %w", i, pattern, err) 27 | } 28 | tables = append(tables, t) 29 | } 30 | return &TableSet{Tables: tables}, nil 31 | } 32 | 33 | func (ts *TableSet) Candidates() []*Table { 34 | if ts == nil { 35 | return nil 36 | } 37 | return ts.Tables 38 | } 39 | -------------------------------------------------------------------------------- /pkg/obfs/httpmask/masker_extra_test.go: -------------------------------------------------------------------------------- 1 | package httpmask 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func TestWriteRandomRequestHeader(t *testing.T) { 11 | var buf bytes.Buffer 12 | if err := WriteRandomRequestHeader(&buf, "example.com"); err != nil { 13 | t.Fatalf("WriteRandomRequestHeader error: %v", err) 14 | } 15 | raw := buf.String() 16 | if !(strings.HasPrefix(raw, "POST ") || strings.HasPrefix(raw, "GET ")) { 17 | t.Fatalf("invalid request line: %q", raw) 18 | } 19 | if !strings.Contains(raw, "Host: example.com") { 20 | t.Fatalf("missing host header") 21 | } 22 | if !strings.Contains(raw, "\r\n\r\n") { 23 | t.Fatalf("missing header terminator") 24 | } 25 | } 26 | 27 | func TestConsumeHeader(t *testing.T) { 28 | req := "POST /test HTTP/1.1\r\nHost: a\r\n\r\nBODY" 29 | r := bufio.NewReader(strings.NewReader(req)) 30 | consumed, err := ConsumeHeader(r) 31 | if err != nil { 32 | t.Fatalf("ConsumeHeader error: %v", err) 33 | } 34 | if string(consumed) != "POST /test HTTP/1.1\r\nHost: a\r\n\r\n" { 35 | t.Fatalf("unexpected consumed data: %q", string(consumed)) 36 | } 37 | rest, _ := r.ReadString('\n') 38 | if rest != "BODY" { 39 | t.Fatalf("body not left in reader, got %q", rest) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/config/init_config.go: -------------------------------------------------------------------------------- 1 | // internal/config/init_config.go 2 | package config 3 | 4 | import ( 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | ) 9 | 10 | func Load(path string) (*Config, error) { 11 | f, err := os.Open(path) 12 | if err != nil { 13 | return nil, err 14 | } 15 | defer f.Close() 16 | 17 | cfg := Config{ 18 | EnablePureDownlink: true, 19 | HTTPMaskMode: "legacy", 20 | } 21 | if err := json.NewDecoder(f).Decode(&cfg); err != nil { 22 | return nil, err 23 | } 24 | 25 | if cfg.Transport == "" { 26 | cfg.Transport = "tcp" 27 | } 28 | 29 | if cfg.ASCII == "" { 30 | cfg.ASCII = "prefer_entropy" 31 | } 32 | if cfg.HTTPMaskMode == "" { 33 | cfg.HTTPMaskMode = "legacy" 34 | } 35 | 36 | if !cfg.EnablePureDownlink && cfg.AEAD == "none" { 37 | return nil, fmt.Errorf("enable_pure_downlink=false requires AEAD to be enabled") 38 | } 39 | 40 | // 处理 ProxyMode 和 默认规则 41 | // 如果用户显式设置了 rule_urls 为 ["global"] 或 ["direct"],则覆盖模式 42 | if len(cfg.RuleURLs) > 0 && (cfg.RuleURLs[0] == "global" || cfg.RuleURLs[0] == "direct") { 43 | cfg.ProxyMode = cfg.RuleURLs[0] 44 | cfg.RuleURLs = nil 45 | } else if len(cfg.RuleURLs) > 0 { 46 | cfg.ProxyMode = "pac" 47 | } else { 48 | if cfg.ProxyMode == "" { 49 | cfg.ProxyMode = "global" // 默认为全局代理模式 50 | } 51 | } 52 | 53 | return &cfg, nil 54 | } 55 | -------------------------------------------------------------------------------- /internal/handler/fallback.go: -------------------------------------------------------------------------------- 1 | // internal/handler/fallback.go 2 | package handler 3 | 4 | import ( 5 | "io" 6 | "log" 7 | "net" 8 | "sync" 9 | "time" 10 | 11 | "github.com/saba-futai/sudoku/internal/config" 12 | ) 13 | 14 | func HandleSuspicious(wrapper net.Conn, rawConn net.Conn, cfg *config.Config) { 15 | remoteAddr := rawConn.RemoteAddr().String() 16 | 17 | if cfg.SuspiciousAction == "silent" { 18 | log.Printf("[Silent] Suspicious %s. Tarpit.", remoteAddr) 19 | io.Copy(io.Discard, rawConn) 20 | time.Sleep(5 * time.Second) 21 | rawConn.Close() 22 | return 23 | } 24 | 25 | if cfg.FallbackAddr == "" { 26 | rawConn.Close() 27 | return 28 | } 29 | 30 | log.Printf("[Fallback] %s -> %s", remoteAddr, cfg.FallbackAddr) 31 | dst, err := net.DialTimeout("tcp", cfg.FallbackAddr, 3*time.Second) 32 | if err != nil { 33 | rawConn.Close() 34 | return 35 | } 36 | 37 | var badData []byte 38 | if recorder, ok := wrapper.(interface{ GetBufferedAndRecorded() []byte }); ok { 39 | badData = recorder.GetBufferedAndRecorded() 40 | } 41 | 42 | if len(badData) > 0 { 43 | if _, err := dst.Write(badData); err != nil { 44 | dst.Close() 45 | rawConn.Close() 46 | return 47 | } 48 | } 49 | 50 | var wg sync.WaitGroup 51 | wg.Add(2) 52 | go func() { 53 | defer wg.Done() 54 | defer dst.Close() 55 | // 将剩余的 rawConn 数据转发给 dst 56 | io.Copy(dst, rawConn) 57 | }() 58 | go func() { 59 | defer wg.Done() 60 | defer rawConn.Close() 61 | io.Copy(rawConn, dst) 62 | }() 63 | wg.Wait() 64 | } 65 | -------------------------------------------------------------------------------- /internal/handler/fallback_test.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "io" 5 | "net" 6 | "testing" 7 | "time" 8 | 9 | "github.com/saba-futai/sudoku/internal/config" 10 | ) 11 | 12 | type recordedConn struct { 13 | net.Conn 14 | data []byte 15 | } 16 | 17 | func (r *recordedConn) GetBufferedAndRecorded() []byte { 18 | return r.data 19 | } 20 | 21 | func TestHandleSuspiciousFallback(t *testing.T) { 22 | l, err := net.Listen("tcp", "127.0.0.1:0") 23 | if err != nil { 24 | t.Fatalf("listen: %v", err) 25 | } 26 | defer l.Close() 27 | 28 | got := make(chan []byte, 1) 29 | go func() { 30 | conn, err := l.Accept() 31 | if err != nil { 32 | return 33 | } 34 | defer conn.Close() 35 | all, _ := io.ReadAll(conn) 36 | got <- all 37 | }() 38 | 39 | clientSide, serverSide := net.Pipe() 40 | defer clientSide.Close() 41 | defer serverSide.Close() 42 | 43 | cfg := &config.Config{ 44 | FallbackAddr: l.Addr().String(), 45 | } 46 | 47 | wrapper := &recordedConn{Conn: serverSide, data: []byte("bad")} 48 | 49 | go HandleSuspicious(wrapper, serverSide, cfg) 50 | 51 | // Write extra data that should also be forwarded 52 | if _, err := clientSide.Write([]byte("tail")); err != nil { 53 | t.Fatalf("write: %v", err) 54 | } 55 | clientSide.Close() 56 | 57 | select { 58 | case data := <-got: 59 | if string(data) != "badtail" { 60 | t.Fatalf("unexpected fallback data: %q", string(data)) 61 | } 62 | case <-time.After(5 * time.Second): 63 | t.Fatalf("fallback did not receive data") 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /apis/doc.go: -------------------------------------------------------------------------------- 1 | // Package apis exposes the Sudoku tunnel (HTTP mask + Sudoku obfuscation + AEAD) as a small Go API. 2 | // It supports both pure Sudoku downlink and the bandwidth-optimized packed downlink, plus UDP-over-TCP (UoT), 3 | // so the same primitives used by the CLI can be embedded by other projects. 4 | // 5 | // Key entry points: 6 | // - ProtocolConfig / DefaultConfig: describe all required parameters. 7 | // - Dial: client-side helper that connects to a Sudoku server and sends the target address. 8 | // - DialUDPOverTCP: client-side helper that primes a UoT tunnel. 9 | // - ServerHandshake: server-side helper that upgrades an accepted TCP connection and returns 10 | // the decrypted tunnel plus the requested target address (TCP mode). 11 | // - ServerHandshakeFlexible: server-side helper that upgrades connections and lets callers 12 | // detect UoT or read the target address themselves. 13 | // - HandshakeError: wraps errors while preserving bytes already consumed so callers can 14 | // gracefully fall back to raw TCP/HTTP handling if desired. 15 | // 16 | // The configuration mirrors the CLI behavior: build a Sudoku table via 17 | // sudoku.NewTable(seed, "prefer_ascii"|"prefer_entropy") or sudoku.NewTableWithCustom 18 | // (third arg: custom X/P/V pattern such as "xpxvvpvv"), pick an AEAD (chacha20-poly1305 is 19 | // the default and required when using packed downlink), keep the key and padding settings 20 | // consistent across client/server, and apply an optional handshake timeout on the server side. 21 | package apis 22 | -------------------------------------------------------------------------------- /internal/config/init_config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | ) 8 | 9 | func TestLoadDefaults(t *testing.T) { 10 | tmpDir := t.TempDir() 11 | path := filepath.Join(tmpDir, "cfg.json") 12 | 13 | data := `{ 14 | "mode": "client", 15 | "local_port": 8080, 16 | "server_address": "1.1.1.1:443", 17 | "key": "k", 18 | "aead": "none", 19 | "rule_urls": ["global"] 20 | }` 21 | if err := os.WriteFile(path, []byte(data), 0o644); err != nil { 22 | t.Fatalf("write file: %v", err) 23 | } 24 | 25 | cfg, err := Load(path) 26 | if err != nil { 27 | t.Fatalf("Load error: %v", err) 28 | } 29 | 30 | if cfg.Transport != "tcp" { 31 | t.Fatalf("Transport default not applied") 32 | } 33 | if cfg.ASCII != "prefer_entropy" { 34 | t.Fatalf("ASCII default not applied, got %s", cfg.ASCII) 35 | } 36 | if cfg.ProxyMode != "global" || cfg.RuleURLs != nil { 37 | t.Fatalf("ProxyMode parsing failed, mode=%s urls=%v", cfg.ProxyMode, cfg.RuleURLs) 38 | } 39 | if !cfg.EnablePureDownlink { 40 | t.Fatalf("EnablePureDownlink should default to true") 41 | } 42 | } 43 | 44 | func TestLoadRejectsPackedWithoutAEAD(t *testing.T) { 45 | tmpDir := t.TempDir() 46 | path := filepath.Join(tmpDir, "cfg.json") 47 | 48 | data := `{ 49 | "mode": "server", 50 | "local_port": 8080, 51 | "server_address": "0.0.0.0:8080", 52 | "key": "k", 53 | "aead": "none", 54 | "enable_pure_downlink": false 55 | }` 56 | if err := os.WriteFile(path, []byte(data), 0o644); err != nil { 57 | t.Fatalf("write file: %v", err) 58 | } 59 | 60 | if _, err := Load(path); err == nil { 61 | t.Fatalf("expected error when packed downlink used without AEAD") 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /pkg/obfs/httpmask/masker_test.go: -------------------------------------------------------------------------------- 1 | package httpmask 2 | 3 | import ( 4 | "bufio" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestConsumeHeader_ReturnsConsumedData(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | input string 13 | wantErr bool 14 | }{ 15 | { 16 | name: "Valid POST request", 17 | input: "POST /api/v1/upload HTTP/1.1\r\n" + 18 | "Host: example.com\r\n" + 19 | "User-Agent: Go-Test\r\n" + 20 | "\r\n" + 21 | "Body data", 22 | wantErr: false, 23 | }, 24 | { 25 | name: "Valid GET request", 26 | input: "GET /ws HTTP/1.1\r\n" + 27 | "Host: example.com\r\n" + 28 | "\r\n", 29 | wantErr: false, 30 | }, 31 | { 32 | name: "Invalid method", 33 | input: "BREW / HTTP/1.1\r\n" + 34 | "Host: example.com\r\n" + 35 | "\r\n", 36 | wantErr: true, 37 | }, 38 | { 39 | name: "Garbage data", 40 | input: "NotHTTPData\r\n", 41 | wantErr: true, 42 | }, 43 | } 44 | 45 | for _, tt := range tests { 46 | t.Run(tt.name, func(t *testing.T) { 47 | r := bufio.NewReader(strings.NewReader(tt.input)) 48 | consumed, err := ConsumeHeader(r) 49 | 50 | if (err != nil) != tt.wantErr { 51 | t.Errorf("ConsumeHeader() error = %v, wantErr %v", err, tt.wantErr) 52 | } 53 | 54 | // Verify consumed data matches the beginning of input 55 | if !strings.HasPrefix(tt.input, string(consumed)) { 56 | t.Errorf("ConsumeHeader() consumed data mismatch.\nGot: %q\nInput starts with: %q", consumed, tt.input[:len(consumed)]) 57 | } 58 | 59 | // If success, verify we consumed up to the empty line 60 | if !tt.wantErr { 61 | expectedHeaderEnd := "\r\n\r\n" 62 | if !strings.Contains(string(consumed), expectedHeaderEnd) { 63 | t.Errorf("ConsumeHeader() did not consume full header. Got: %q", consumed) 64 | } 65 | } 66 | }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Binary name 2 | BINARY_NAME=sudoku 3 | MAIN_PATH=./cmd/sudoku-tunnel 4 | 5 | # Build flags 6 | # -s: Omit the symbol table and debug information 7 | # -w: Omit the DWARF symbol table 8 | LDFLAGS=-ldflags "-s -w" 9 | 10 | .PHONY: all build clean test build-all help 11 | 12 | default: build 13 | 14 | # Build the binary for the current OS/ARCH 15 | build: 16 | @echo "Building $(BINARY_NAME)..." 17 | @mkdir -p bin 18 | go build $(LDFLAGS) -o bin/$(BINARY_NAME) $(MAIN_PATH) 19 | @echo "Build complete: bin/$(BINARY_NAME)" 20 | 21 | # Run tests 22 | test: 23 | @echo "Running tests..." 24 | go test -v ./... 25 | 26 | # Clean build artifacts 27 | clean: 28 | @echo "Cleaning..." 29 | @rm -rf bin 30 | @go clean 31 | 32 | # Cross-compile for common platforms locally (useful for quick checks) 33 | build-all: clean 34 | @echo "Building for multiple platforms..." 35 | @mkdir -p bin 36 | # Linux 37 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-linux-amd64 $(MAIN_PATH) 38 | CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-linux-arm64 $(MAIN_PATH) 39 | # Windows 40 | CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-windows-amd64.exe $(MAIN_PATH) 41 | # macOS (Darwin) 42 | CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-darwin-amd64 $(MAIN_PATH) 43 | CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-darwin-arm64 $(MAIN_PATH) 44 | @echo "All builds complete." 45 | 46 | # Show help 47 | help: 48 | @echo "Usage: make [target]" 49 | @echo "" 50 | @echo "Targets:" 51 | @echo " build Build for current OS (default)" 52 | @echo " test Run unit tests" 53 | @echo " clean Remove bin directory" 54 | @echo " build-all Cross-compile for Linux, Windows, and Darwin (AMD64/ARM64)" -------------------------------------------------------------------------------- /pkg/crypto/aead_conn_test.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "io" 5 | "net" 6 | "testing" 7 | ) 8 | 9 | func TestAEADConnRoundTrip_Chacha(t *testing.T) { 10 | left, right := net.Pipe() 11 | defer left.Close() 12 | defer right.Close() 13 | 14 | connA, err := NewAEADConn(left, "secret-key", "chacha20-poly1305") 15 | if err != nil { 16 | t.Fatalf("NewAEADConn A error: %v", err) 17 | } 18 | connB, err := NewAEADConn(right, "secret-key", "chacha20-poly1305") 19 | if err != nil { 20 | t.Fatalf("NewAEADConn B error: %v", err) 21 | } 22 | 23 | msg := []byte("hello aead") 24 | go func() { 25 | defer connA.Close() 26 | if _, err := connA.Write(msg); err != nil { 27 | t.Errorf("write failed: %v", err) 28 | } 29 | }() 30 | 31 | buf := make([]byte, len(msg)) 32 | if _, err := io.ReadFull(connB, buf); err != nil { 33 | t.Fatalf("read failed: %v", err) 34 | } 35 | if string(buf) != string(msg) { 36 | t.Fatalf("payload mismatch, got %q", string(buf)) 37 | } 38 | } 39 | 40 | func TestAEADConnNone_Passthrough(t *testing.T) { 41 | left, right := net.Pipe() 42 | defer left.Close() 43 | defer right.Close() 44 | 45 | connA, err := NewAEADConn(left, "ignored", "none") 46 | if err != nil { 47 | t.Fatalf("NewAEADConn A error: %v", err) 48 | } 49 | connB, err := NewAEADConn(right, "ignored", "none") 50 | if err != nil { 51 | t.Fatalf("NewAEADConn B error: %v", err) 52 | } 53 | 54 | msg := []byte("plain text") 55 | go connA.Write(msg) 56 | 57 | buf := make([]byte, len(msg)) 58 | if _, err := io.ReadFull(connB, buf); err != nil { 59 | t.Fatalf("read failed: %v", err) 60 | } 61 | if string(buf) != string(msg) { 62 | t.Fatalf("payload mismatch, got %q", string(buf)) 63 | } 64 | } 65 | 66 | func TestAEADConnUnsupported(t *testing.T) { 67 | if _, err := NewAEADConn(nil, "key", "invalid"); err == nil { 68 | t.Fatalf("expected error for unsupported cipher") 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /apis/uot.go: -------------------------------------------------------------------------------- 1 | package apis 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net" 8 | 9 | "github.com/saba-futai/sudoku/internal/tunnel" 10 | ) 11 | 12 | // DialUDPOverTCP bootstraps a UDP-over-TCP tunnel using the standard Dial flow. 13 | func DialUDPOverTCP(ctx context.Context, cfg *ProtocolConfig) (net.Conn, error) { 14 | conn, err := establishBaseConn(ctx, cfg, validateUoTConfig) 15 | if err != nil { 16 | return nil, err 17 | } 18 | if err := tunnel.WriteUoTPreface(conn); err != nil { 19 | conn.Close() 20 | return nil, fmt.Errorf("write uot preface: %w", err) 21 | } 22 | return conn, nil 23 | } 24 | 25 | // DetectUoT peeks the first payload byte and returns a conn that can be used normally 26 | // (with the byte re-inserted) when the stream is not a UoT session. 27 | func DetectUoT(conn net.Conn) (bool, net.Conn, error) { 28 | first := []byte{0} 29 | if _, err := io.ReadFull(conn, first); err != nil { 30 | return false, conn, err 31 | } 32 | if first[0] != tunnel.UoTMagicByte { 33 | return false, &prebufferConn{Conn: conn, buf: first}, nil 34 | } 35 | return true, conn, nil 36 | } 37 | 38 | // HandleUoT runs the UDP-over-TCP loop on an upgraded tunnel connection. 39 | func HandleUoT(conn net.Conn) error { 40 | return tunnel.HandleUoTServer(conn) 41 | } 42 | 43 | func WriteUoTDatagram(w io.Writer, addr string, payload []byte) error { 44 | return tunnel.WriteUoTDatagram(w, addr, payload) 45 | } 46 | 47 | func ReadUoTDatagram(r io.Reader) (string, []byte, error) { 48 | return tunnel.ReadUoTDatagram(r) 49 | } 50 | 51 | // prebufferConn replays buffered bytes before reading from the underlying connection. 52 | type prebufferConn struct { 53 | net.Conn 54 | buf []byte 55 | } 56 | 57 | func (p *prebufferConn) Read(b []byte) (int, error) { 58 | if len(p.buf) > 0 { 59 | n := copy(b, p.buf) 60 | p.buf = p.buf[n:] 61 | return n, nil 62 | } 63 | return p.Conn.Read(b) 64 | } 65 | -------------------------------------------------------------------------------- /apis/config_test.go: -------------------------------------------------------------------------------- 1 | package apis 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/saba-futai/sudoku/pkg/obfs/sudoku" 7 | ) 8 | 9 | func TestValidateClient(t *testing.T) { 10 | cfg := &ProtocolConfig{ 11 | Table: sudoku.NewTable("seed", "prefer_ascii"), 12 | Key: "k", 13 | AEADMethod: "chacha20-poly1305", 14 | PaddingMin: 5, 15 | PaddingMax: 10, 16 | EnablePureDownlink: true, 17 | ServerAddress: "1.1.1.1:443", 18 | TargetAddress: "example.com:80", 19 | } 20 | if err := cfg.ValidateClient(); err != nil { 21 | t.Fatalf("ValidateClient unexpected error: %v", err) 22 | } 23 | 24 | cfg.PaddingMax = 1 25 | cfg.PaddingMin = 2 26 | if err := cfg.Validate(); err == nil { 27 | t.Fatalf("expected padding validation error") 28 | } 29 | 30 | cfg.PaddingMin = 0 31 | cfg.PaddingMax = 0 32 | cfg.AEADMethod = "bad" 33 | if err := cfg.Validate(); err == nil { 34 | t.Fatalf("expected invalid AEAD error") 35 | } 36 | 37 | cfg.AEADMethod = "none" 38 | cfg.Table = nil 39 | if err := cfg.Validate(); err == nil { 40 | t.Fatalf("expected table nil error") 41 | } 42 | 43 | cfg.Table = sudoku.NewTable("seed", "prefer_ascii") 44 | cfg.ServerAddress = "" 45 | if err := cfg.ValidateClient(); err == nil { 46 | t.Fatalf("expected server address error") 47 | } 48 | cfg.ServerAddress = "1.1.1.1:443" 49 | cfg.TargetAddress = "" 50 | if err := cfg.ValidateClient(); err == nil { 51 | t.Fatalf("expected target address error") 52 | } 53 | 54 | cfg.TargetAddress = "example.com:80" 55 | cfg.EnablePureDownlink = false 56 | cfg.AEADMethod = "none" 57 | if err := cfg.Validate(); err == nil { 58 | t.Fatalf("expected downlink AEAD validation error") 59 | } 60 | } 61 | 62 | func TestDefaultConfig(t *testing.T) { 63 | cfg := DefaultConfig() 64 | if cfg.AEADMethod == "" || cfg.PaddingMin == 0 || cfg.PaddingMax == 0 || !cfg.EnablePureDownlink { 65 | t.Fatalf("defaults not set") 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /apis/obfs.go: -------------------------------------------------------------------------------- 1 | package apis 2 | 3 | import ( 4 | "io" 5 | "net" 6 | 7 | "github.com/saba-futai/sudoku/pkg/obfs/sudoku" 8 | ) 9 | 10 | const ( 11 | downlinkModePure byte = 0x01 12 | downlinkModePacked byte = 0x02 13 | ) 14 | 15 | type directionalConn struct { 16 | net.Conn 17 | reader io.Reader 18 | writer io.Writer 19 | closers []func() error 20 | } 21 | 22 | func (c *directionalConn) Read(p []byte) (int, error) { 23 | return c.reader.Read(p) 24 | } 25 | 26 | func (c *directionalConn) Write(p []byte) (int, error) { 27 | return c.writer.Write(p) 28 | } 29 | 30 | func (c *directionalConn) Close() error { 31 | var firstErr error 32 | for _, fn := range c.closers { 33 | if fn == nil { 34 | continue 35 | } 36 | if err := fn(); err != nil && firstErr == nil { 37 | firstErr = err 38 | } 39 | } 40 | if err := c.Conn.Close(); err != nil && firstErr == nil { 41 | firstErr = err 42 | } 43 | return firstErr 44 | } 45 | 46 | func downlinkMode(cfg *ProtocolConfig) byte { 47 | if cfg.EnablePureDownlink { 48 | return downlinkModePure 49 | } 50 | return downlinkModePacked 51 | } 52 | 53 | func buildClientObfsConn(raw net.Conn, cfg *ProtocolConfig, table *sudoku.Table) net.Conn { 54 | base := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false) 55 | if cfg.EnablePureDownlink { 56 | return base 57 | } 58 | packed := sudoku.NewPackedConn(raw, table, cfg.PaddingMin, cfg.PaddingMax) 59 | return &directionalConn{ 60 | Conn: raw, 61 | reader: packed, 62 | writer: base, 63 | } 64 | } 65 | 66 | func buildServerObfsConn(raw net.Conn, cfg *ProtocolConfig, table *sudoku.Table, record bool) (*sudoku.Conn, net.Conn) { 67 | uplink := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, record) 68 | if cfg.EnablePureDownlink { 69 | return uplink, uplink 70 | } 71 | packed := sudoku.NewPackedConn(raw, table, cfg.PaddingMin, cfg.PaddingMax) 72 | return uplink, &directionalConn{ 73 | Conn: raw, 74 | reader: uplink, 75 | writer: packed, 76 | closers: []func() error{packed.Flush}, 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /pkg/obfs/sudoku/benchmark_test.go: -------------------------------------------------------------------------------- 1 | package sudoku 2 | 3 | import ( 4 | "io" 5 | "math/rand" 6 | "net" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | // MockConn implements net.Conn for benchmarking 12 | type MockConn struct { 13 | readBuf []byte 14 | writeBuf []byte 15 | } 16 | 17 | func (m *MockConn) Read(b []byte) (n int, err error) { 18 | if len(m.readBuf) == 0 { 19 | return 0, io.EOF 20 | } 21 | n = copy(b, m.readBuf) 22 | m.readBuf = m.readBuf[n:] 23 | return n, nil 24 | } 25 | 26 | func (m *MockConn) Write(b []byte) (n int, err error) { 27 | m.writeBuf = append(m.writeBuf, b...) 28 | return len(b), nil 29 | } 30 | 31 | func (m *MockConn) Close() error { return nil } 32 | func (m *MockConn) LocalAddr() net.Addr { return nil } 33 | func (m *MockConn) RemoteAddr() net.Addr { return nil } 34 | func (m *MockConn) SetDeadline(t time.Time) error { return nil } 35 | func (m *MockConn) SetReadDeadline(t time.Time) error { return nil } 36 | func (m *MockConn) SetWriteDeadline(t time.Time) error { return nil } 37 | 38 | func BenchmarkSudokuWrite(b *testing.B) { 39 | key := "benchmark-key" 40 | table := NewTable(key, "prefer_ascii") 41 | mock := &MockConn{} 42 | conn := NewConn(mock, table, 10, 20, false) 43 | 44 | data := make([]byte, 1024) 45 | rand.Read(data) 46 | 47 | b.ResetTimer() 48 | b.ReportAllocs() 49 | for i := 0; i < b.N; i++ { 50 | mock.writeBuf = mock.writeBuf[:0] // Reset buffer 51 | conn.Write(data) 52 | } 53 | } 54 | 55 | func BenchmarkSudokuRead(b *testing.B) { 56 | key := "benchmark-key" 57 | table := NewTable(key, "prefer_ascii") 58 | 59 | // Pre-generate encoded data 60 | mock := &MockConn{} 61 | writerConn := NewConn(mock, table, 10, 20, false) 62 | data := make([]byte, 1024) 63 | rand.Read(data) 64 | writerConn.Write(data) 65 | encodedData := mock.writeBuf 66 | 67 | b.ResetTimer() 68 | b.ReportAllocs() 69 | for i := 0; i < b.N; i++ { 70 | // Reset reader state 71 | mock.readBuf = encodedData 72 | readerConn := NewConn(mock, table, 10, 20, false) 73 | buf := make([]byte, 1024) 74 | io.ReadFull(readerConn, buf) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | // internal/config/config.go 2 | package config 3 | 4 | type Config struct { 5 | Mode string `json:"mode"` // "client" or "server" 6 | Transport string `json:"transport"` // "tcp" or "udp" 7 | LocalPort int `json:"local_port"` 8 | ServerAddress string `json:"server_address"` 9 | FallbackAddr string `json:"fallback_address"` 10 | Key string `json:"key"` 11 | AEAD string `json:"aead"` // "aes-128-gcm", "chacha20-poly1305", "none" 12 | SuspiciousAction string `json:"suspicious_action"` // "fallback" or "silent" 13 | PaddingMin int `json:"padding_min"` 14 | PaddingMax int `json:"padding_max"` 15 | RuleURLs []string `json:"rule_urls"` // 留空则使用默认,支持 "global", "direct" 关键字 16 | ProxyMode string `json:"proxy_mode"` // 运行时状态,非JSON字段,由Load解析逻辑填充 17 | ASCII string `json:"ascii"` // "prefer_entropy" (默认): 低熵, "prefer_ascii": 纯ASCII字符,高熵 18 | CustomTable string `json:"custom_table"` // 可选,定义 X/P/V 布局,如 "xpxvvpvv" 19 | CustomTables []string `json:"custom_tables"` // 可选,多套 X/P/V 布局轮换 20 | EnablePureDownlink bool `json:"enable_pure_downlink"` // 启用纯 Sudoku 下行;false 时使用带宽优化下行编码 21 | DisableHTTPMask bool `json:"disable_http_mask"` 22 | // HTTPMaskMode controls how the "HTTP mask" layer behaves: 23 | // - "legacy": write a fake HTTP/1.1 header then switch to raw stream (default, not CDN-compatible) 24 | // - "xhttp": real HTTP tunnel (stream-one), CDN-compatible 25 | // - "pht": plain HTTP tunnel (authorize/push/pull), strong restricted-network pass-through 26 | // - "auto": try xhttp then fall back to pht 27 | HTTPMaskMode string `json:"http_mask_mode"` 28 | // HTTPMaskTLS enables HTTPS for HTTP tunnel modes (client-side). If unset/false, the default is auto-inferred 29 | // from server port (443 => HTTPS, otherwise HTTP). 30 | HTTPMaskTLS bool `json:"http_mask_tls"` 31 | // HTTPMaskHost optionally overrides the HTTP Host header / SNI host for HTTP tunnel modes (client-side). 32 | // When empty, it is derived from ServerAddress. 33 | HTTPMaskHost string `json:"http_mask_host"` 34 | } 35 | -------------------------------------------------------------------------------- /apis/errors.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) 2025 by ふたい 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | 17 | In addition, no derivative work may use the name or imply association 18 | with this application without prior consent. 19 | */ 20 | package apis 21 | 22 | import ( 23 | "fmt" 24 | "net" 25 | ) 26 | 27 | // HandshakeError 包装了握手过程中的错误 28 | // 如果发生此错误,调用者可以通过 RawConn、HTTPHeaderData 和 ReadData 进行回落处理 29 | // 30 | // 字段说明: 31 | // - Err: 原始错误,说明握手失败的具体原因 32 | // - RawConn: 原始 TCP 连接,可用于回落处理 33 | // - HTTPHeaderData: HTTP 伪装层读取的头部字节(在 ConsumeHeader 阶段收集) 34 | // - ReadData: Sudoku 层读取并记录的字节(在 Sudoku 解码阶段收集) 35 | // 36 | // 回落处理时的数据重放顺序: 37 | // 1. 首先写入 HTTPHeaderData(如果非空) 38 | // 2. 然后写入 ReadData(如果非空) 39 | // 3. 最后转发 RawConn 中的剩余数据 40 | // 41 | // 示例用法: 42 | // 43 | // conn, target, err := apis.ServerHandshake(rawConn, cfg) 44 | // if err != nil { 45 | // var hsErr *apis.HandshakeError 46 | // if errors.As(err, &hsErr) { 47 | // // 可以进行回落处理 48 | // fallbackConn, _ := net.Dial("tcp", fallbackAddr) 49 | // fallbackConn.Write(hsErr.HTTPHeaderData) 50 | // fallbackConn.Write(hsErr.ReadData) 51 | // io.Copy(fallbackConn, hsErr.RawConn) 52 | // io.Copy(hsErr.RawConn, fallbackConn) 53 | // } 54 | // return 55 | // } 56 | type HandshakeError struct { 57 | Err error 58 | RawConn net.Conn 59 | HTTPHeaderData []byte // HTTP 伪装层头部数据 60 | ReadData []byte // Sudoku 层已读取的数据 61 | } 62 | 63 | func (e *HandshakeError) Error() string { 64 | return fmt.Sprintf("sudoku handshake failed: %v", e.Err) 65 | } 66 | 67 | func (e *HandshakeError) Unwrap() error { 68 | return e.Err 69 | } 70 | -------------------------------------------------------------------------------- /internal/protocol/address_test.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestWriteReadAddress_IPv4(t *testing.T) { 9 | buf := new(bytes.Buffer) 10 | addr := "1.2.3.4:8080" 11 | 12 | if err := WriteAddress(buf, addr); err != nil { 13 | t.Fatalf("WriteAddress error: %v", err) 14 | } 15 | 16 | got, typ, ip, err := ReadAddress(buf) 17 | if err != nil { 18 | t.Fatalf("ReadAddress error: %v", err) 19 | } 20 | if got != addr { 21 | t.Fatalf("addr mismatch, got %s", got) 22 | } 23 | if typ != AddrTypeIPv4 { 24 | t.Fatalf("type mismatch, got %d", typ) 25 | } 26 | if ip == nil { 27 | t.Fatalf("ip should not be nil for ipv4") 28 | } 29 | } 30 | 31 | func TestWriteReadAddress_Domain(t *testing.T) { 32 | buf := new(bytes.Buffer) 33 | addr := "example.com:53" 34 | 35 | if err := WriteAddress(buf, addr); err != nil { 36 | t.Fatalf("WriteAddress error: %v", err) 37 | } 38 | 39 | got, typ, ip, err := ReadAddress(buf) 40 | if err != nil { 41 | t.Fatalf("ReadAddress error: %v", err) 42 | } 43 | if got != addr { 44 | t.Fatalf("addr mismatch, got %s", got) 45 | } 46 | if typ != AddrTypeDomain { 47 | t.Fatalf("type mismatch, got %d", typ) 48 | } 49 | if ip != nil { 50 | t.Fatalf("expected nil ip for domain, got %v", ip) 51 | } 52 | } 53 | 54 | func TestWriteReadAddress_IPv6(t *testing.T) { 55 | buf := new(bytes.Buffer) 56 | addr := "[2001:db8::1]:443" 57 | 58 | if err := WriteAddress(buf, addr); err != nil { 59 | t.Fatalf("WriteAddress error: %v", err) 60 | } 61 | 62 | got, typ, ip, err := ReadAddress(buf) 63 | if err != nil { 64 | t.Fatalf("ReadAddress error: %v", err) 65 | } 66 | if got != addr { 67 | t.Fatalf("addr mismatch, got %s", got) 68 | } 69 | if typ != AddrTypeIPv6 { 70 | t.Fatalf("type mismatch, got %d", typ) 71 | } 72 | if ip == nil { 73 | t.Fatalf("ip should not be nil for ipv6") 74 | } 75 | } 76 | 77 | func TestWriteAddress_DomainTooLong(t *testing.T) { 78 | longDomain := make([]byte, 256) 79 | for i := range longDomain { 80 | longDomain[i] = 'a' 81 | } 82 | err := WriteAddress(new(bytes.Buffer), string(longDomain)+":80") 83 | if err == nil { 84 | t.Fatalf("expected error for long domain") 85 | } 86 | } 87 | 88 | func TestReadAddress_UnknownType(t *testing.T) { 89 | buf := bytes.NewBuffer([]byte{0x02, 0x00, 0x50}) // invalid type, port after 90 | if _, _, _, err := ReadAddress(buf); err == nil { 91 | t.Fatalf("expected error for unknown type") 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /internal/tunnel/obfs_conn.go: -------------------------------------------------------------------------------- 1 | package tunnel 2 | 3 | import ( 4 | "io" 5 | "net" 6 | 7 | "github.com/saba-futai/sudoku/internal/config" 8 | "github.com/saba-futai/sudoku/pkg/obfs/sudoku" 9 | ) 10 | 11 | const ( 12 | DownlinkModePure byte = 0x01 13 | DownlinkModePacked byte = 0x02 14 | ) 15 | 16 | type directionalConn struct { 17 | net.Conn 18 | reader io.Reader 19 | writer io.Writer 20 | closers []func() error 21 | } 22 | 23 | func newDirectionalConn(base net.Conn, reader io.Reader, writer io.Writer, closers ...func() error) net.Conn { 24 | return &directionalConn{ 25 | Conn: base, 26 | reader: reader, 27 | writer: writer, 28 | closers: closers, 29 | } 30 | } 31 | 32 | func (c *directionalConn) Read(p []byte) (int, error) { 33 | return c.reader.Read(p) 34 | } 35 | 36 | func (c *directionalConn) Write(p []byte) (int, error) { 37 | return c.writer.Write(p) 38 | } 39 | 40 | func (c *directionalConn) Close() error { 41 | var firstErr error 42 | for _, fn := range c.closers { 43 | if fn == nil { 44 | continue 45 | } 46 | if err := fn(); err != nil && firstErr == nil { 47 | firstErr = err 48 | } 49 | } 50 | if err := c.Conn.Close(); err != nil && firstErr == nil { 51 | firstErr = err 52 | } 53 | return firstErr 54 | } 55 | 56 | func downlinkModeByte(cfg *config.Config) byte { 57 | if cfg.EnablePureDownlink { 58 | return DownlinkModePure 59 | } 60 | return DownlinkModePacked 61 | } 62 | 63 | // buildObfsConnForClient builds the obfuscation layer for client side, keeping Sudoku on uplink. 64 | func buildObfsConnForClient(raw net.Conn, table *sudoku.Table, cfg *config.Config) net.Conn { 65 | baseSudoku := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false) 66 | if cfg.EnablePureDownlink { 67 | return baseSudoku 68 | } 69 | packed := sudoku.NewPackedConn(raw, table, cfg.PaddingMin, cfg.PaddingMax) 70 | return newDirectionalConn(raw, packed, baseSudoku) 71 | } 72 | 73 | // buildObfsConnForServer builds the obfuscation layer for server side, keeping Sudoku on uplink. 74 | // It returns the reader Sudoku connection (for fallback recording) and the composed net.Conn. 75 | func buildObfsConnForServer(raw net.Conn, table *sudoku.Table, cfg *config.Config, record bool) (*sudoku.Conn, net.Conn) { 76 | uplinkSudoku := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, record) 77 | if cfg.EnablePureDownlink { 78 | return uplinkSudoku, uplinkSudoku 79 | } 80 | packed := sudoku.NewPackedConn(raw, table, cfg.PaddingMin, cfg.PaddingMax) 81 | return uplinkSudoku, newDirectionalConn(raw, uplinkSudoku, packed, packed.Flush) 82 | } 83 | -------------------------------------------------------------------------------- /pkg/crypto/key_test.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "encoding/hex" 5 | "testing" 6 | 7 | "filippo.io/edwards25519" 8 | ) 9 | 10 | func TestKeyDerivation(t *testing.T) { 11 | // 1. Generate Master Key 12 | pair, err := GenerateMasterKey() 13 | if err != nil { 14 | t.Fatalf("GenerateMasterKey failed: %v", err) 15 | } 16 | 17 | masterPubHex := EncodePoint(pair.Public) 18 | t.Logf("Master Public: %s", masterPubHex) 19 | 20 | // 2. Split Key 21 | splitKeyHex, err := SplitPrivateKey(pair.Private) 22 | if err != nil { 23 | t.Fatalf("SplitPrivateKey failed: %v", err) 24 | } 25 | t.Logf("Split Key: %s", splitKeyHex) 26 | 27 | // 3. Recover Public Key from Split Key 28 | recoveredPub, err := RecoverPublicKey(splitKeyHex) 29 | if err != nil { 30 | t.Fatalf("RecoverPublicKey failed: %v", err) 31 | } 32 | recoveredPubHex := EncodePoint(recoveredPub) 33 | t.Logf("Recovered Public: %s", recoveredPubHex) 34 | 35 | // 4. Verify Equality 36 | if masterPubHex != recoveredPubHex { 37 | t.Errorf("Public Keys do not match!\nMaster: %s\nRecovered: %s", masterPubHex, recoveredPubHex) 38 | } 39 | 40 | // 5. Verify Recover from Master Scalar 41 | masterScalarHex := EncodeScalar(pair.Private) 42 | recoveredFromMaster, err := RecoverPublicKey(masterScalarHex) 43 | if err != nil { 44 | t.Fatalf("RecoverPublicKey(Master) failed: %v", err) 45 | } 46 | if EncodePoint(recoveredFromMaster) != masterPubHex { 47 | t.Errorf("Recovered from Master Scalar does not match!") 48 | } 49 | 50 | // 6. Test RecoverPublicKey from origin masterScalarHex 51 | pair, _ = GenerateMasterKey() 52 | X := EncodeScalar(pair.Private) 53 | recoveredFromOrigin, err := RecoverPublicKey(X) 54 | if err != nil { 55 | t.Fatalf("RecoverPublicKey(Origin) failed: %v", err) 56 | } else { 57 | t.Logf("Recovered from Origin %s :\n %s", EncodePoint(pair.Public), EncodePoint(recoveredFromOrigin)) 58 | } 59 | 60 | } 61 | 62 | func TestHomomorphicProperty(t *testing.T) { 63 | // Verify P = (r + k)G 64 | pair, _ := GenerateMasterKey() 65 | 66 | splitHex, _ := SplitPrivateKey(pair.Private) 67 | splitBytes, _ := hex.DecodeString(splitHex) 68 | 69 | rBytes := splitBytes[:32] 70 | kBytes := splitBytes[32:] 71 | 72 | r, _ := edwards25519.NewScalar().SetCanonicalBytes(rBytes) 73 | k, _ := edwards25519.NewScalar().SetCanonicalBytes(kBytes) 74 | 75 | // sum = r + k 76 | sum := new(edwards25519.Scalar).Add(r, k) 77 | 78 | // P' = sum * G 79 | P_prime := new(edwards25519.Point).ScalarBaseMult(sum) 80 | 81 | if EncodePoint(P_prime) != EncodePoint(pair.Public) { 82 | t.Errorf("Homomorphic property failed!") 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /apis/httpmask_tunnel_server.go: -------------------------------------------------------------------------------- 1 | package apis 2 | 3 | import ( 4 | "net" 5 | "strings" 6 | 7 | "github.com/saba-futai/sudoku/pkg/obfs/httpmask" 8 | ) 9 | 10 | // HTTPMaskTunnelServer bridges raw TCP accepts with the optional CDN-capable HTTP tunnel transports (xhttp/pht). 11 | // 12 | // Typical usage: 13 | // 14 | // srv := apis.NewHTTPMaskTunnelServer(serverCfg) 15 | // for { 16 | // c, _ := ln.Accept() 17 | // go func(raw net.Conn) { 18 | // defer raw.Close() 19 | // tunnel, target, handled, err := srv.HandleConn(raw) 20 | // if err != nil || !handled || tunnel == nil { 21 | // return 22 | // } 23 | // defer tunnel.Close() 24 | // io.Copy(tunnel, tunnel) 25 | // }(c) 26 | // } 27 | type HTTPMaskTunnelServer struct { 28 | cfg *ProtocolConfig 29 | ts *httpmask.TunnelServer 30 | } 31 | 32 | func NewHTTPMaskTunnelServer(cfg *ProtocolConfig) *HTTPMaskTunnelServer { 33 | if cfg == nil { 34 | return &HTTPMaskTunnelServer{} 35 | } 36 | 37 | var ts *httpmask.TunnelServer 38 | if !cfg.DisableHTTPMask { 39 | switch strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) { 40 | case "xhttp", "pht", "auto": 41 | ts = httpmask.NewTunnelServer(httpmask.TunnelServerOptions{Mode: cfg.HTTPMaskMode}) 42 | } 43 | } 44 | 45 | return &HTTPMaskTunnelServer{ 46 | cfg: cfg, 47 | ts: ts, 48 | } 49 | } 50 | 51 | // HandleConn handles a single accepted TCP connection. 52 | // 53 | // Returns: 54 | // - tunnelConn/targetAddr if a Sudoku tunnel handshake has been completed (handled=true and tunnelConn != nil) 55 | // - handled=true, tunnelConn=nil for HTTP tunnel control requests (e.g., PHT push/pull) 56 | // - handled=false if this server is not configured to handle HTTP tunnel transports (legacy-only) 57 | func (s *HTTPMaskTunnelServer) HandleConn(rawConn net.Conn) (tunnelConn net.Conn, targetAddr string, handled bool, err error) { 58 | if s == nil || s.cfg == nil { 59 | return nil, "", false, nil 60 | } 61 | 62 | // Legacy-only server behavior. 63 | if s.ts == nil { 64 | tunnelConn, targetAddr, err = ServerHandshake(rawConn, s.cfg) 65 | return tunnelConn, targetAddr, true, err 66 | } 67 | 68 | res, c, err := s.ts.HandleConn(rawConn) 69 | if err != nil { 70 | return nil, "", true, err 71 | } 72 | 73 | switch res { 74 | case httpmask.HandleDone: 75 | return nil, "", true, nil 76 | case httpmask.HandlePassThrough: 77 | tunnelConn, targetAddr, err = ServerHandshake(c, s.cfg) 78 | return tunnelConn, targetAddr, true, err 79 | case httpmask.HandleStartTunnel: 80 | inner := *s.cfg 81 | inner.DisableHTTPMask = true 82 | tunnelConn, targetAddr, err = ServerHandshake(c, &inner) 83 | return tunnelConn, targetAddr, true, err 84 | default: 85 | return nil, "", true, nil 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /internal/protocol/address.go: -------------------------------------------------------------------------------- 1 | // internal/protocol/address.go 2 | package protocol 3 | 4 | import ( 5 | "encoding/binary" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net" 10 | ) 11 | 12 | // AddrType 定义 13 | const ( 14 | AddrTypeIPv4 = 0x01 15 | AddrTypeDomain = 0x03 16 | AddrTypeIPv6 = 0x04 17 | ) 18 | 19 | // ReadAddress 读取 SOCKS5 格式的目标地址 20 | // 返回: 完整地址字符串 (host:port), 地址类型, IP(如果是域名则为nil), error 21 | func ReadAddress(r io.Reader) (string, byte, net.IP, error) { 22 | buf := make([]byte, 262) // Max domain length + overhead 23 | 24 | // 1. 读取地址类型 25 | if _, err := io.ReadFull(r, buf[:1]); err != nil { 26 | return "", 0, nil, err 27 | } 28 | addrType := buf[0] 29 | 30 | var host string 31 | var ip net.IP 32 | 33 | switch addrType { 34 | case AddrTypeIPv4: 35 | if _, err := io.ReadFull(r, buf[:4]); err != nil { 36 | return "", 0, nil, err 37 | } 38 | ip = net.IP(buf[:4]) 39 | host = ip.String() 40 | case AddrTypeDomain: 41 | if _, err := io.ReadFull(r, buf[:1]); err != nil { 42 | return "", 0, nil, err 43 | } 44 | domainLen := int(buf[0]) 45 | if _, err := io.ReadFull(r, buf[:domainLen]); err != nil { 46 | return "", 0, nil, err 47 | } 48 | host = string(buf[:domainLen]) 49 | case AddrTypeIPv6: 50 | if _, err := io.ReadFull(r, buf[:16]); err != nil { 51 | return "", 0, nil, err 52 | } 53 | ip = net.IP(buf[:16]) 54 | host = fmt.Sprintf("[%s]", ip.String()) 55 | default: 56 | return "", 0, nil, fmt.Errorf("unknown address type: %d", addrType) 57 | } 58 | 59 | // 2. 读取端口 60 | if _, err := io.ReadFull(r, buf[:2]); err != nil { 61 | return "", 0, nil, err 62 | } 63 | port := binary.BigEndian.Uint16(buf[:2]) 64 | 65 | return fmt.Sprintf("%s:%d", host, port), addrType, ip, nil 66 | } 67 | 68 | // WriteAddress 将地址写入 Writer (SOCKS5 格式) 69 | // 输入 rawAddr 为 "host:port" 70 | func WriteAddress(w io.Writer, rawAddr string) error { 71 | host, portStr, err := net.SplitHostPort(rawAddr) 72 | if err != nil { 73 | return err 74 | } 75 | portInt, _ := net.LookupPort("tcp", portStr) 76 | 77 | ip := net.ParseIP(host) 78 | 79 | // 构建缓冲 80 | buf := make([]byte, 0, 300) 81 | 82 | if ip != nil { 83 | if ip4 := ip.To4(); ip4 != nil { 84 | buf = append(buf, AddrTypeIPv4) 85 | buf = append(buf, ip4...) 86 | } else { 87 | buf = append(buf, AddrTypeIPv6) 88 | buf = append(buf, ip...) 89 | } 90 | } else { 91 | buf = append(buf, AddrTypeDomain) 92 | if len(host) > 255 { 93 | return errors.New("domain too long") 94 | } 95 | buf = append(buf, byte(len(host))) 96 | buf = append(buf, []byte(host)...) 97 | } 98 | 99 | portBytes := make([]byte, 2) 100 | binary.BigEndian.PutUint16(portBytes, uint16(portInt)) 101 | buf = append(buf, portBytes...) 102 | 103 | _, err = w.Write(buf) 104 | return err 105 | } 106 | -------------------------------------------------------------------------------- /internal/tunnel/tunnel_test.go: -------------------------------------------------------------------------------- 1 | package tunnel 2 | 3 | import ( 4 | "io" 5 | "net" 6 | "sync" 7 | "testing" 8 | "time" 9 | 10 | "github.com/saba-futai/sudoku/internal/config" 11 | "github.com/saba-futai/sudoku/internal/protocol" 12 | "github.com/saba-futai/sudoku/pkg/obfs/sudoku" 13 | ) 14 | 15 | func TestSudokuTunnel_Standard(t *testing.T) { 16 | // 1. Setup Config & Table 17 | cfg := &config.Config{ 18 | Mode: "server", 19 | Transport: "tcp", 20 | ServerAddress: "127.0.0.1:0", // Random port 21 | Key: "test-key-123", 22 | AEAD: "chacha20-poly1305", 23 | PaddingMin: 10, 24 | PaddingMax: 20, 25 | ASCII: "prefer_entropy", 26 | EnablePureDownlink: true, 27 | } 28 | table := sudoku.NewTable(cfg.Key, cfg.ASCII) 29 | 30 | // 2. Start Mock Server 31 | listener, err := net.Listen("tcp", "127.0.0.1:0") 32 | if err != nil { 33 | t.Fatalf("Failed to listen: %v", err) 34 | } 35 | defer listener.Close() 36 | serverAddr := listener.Addr().String() 37 | cfg.ServerAddress = serverAddr 38 | 39 | // Server logic 40 | go func() { 41 | for { 42 | conn, err := listener.Accept() 43 | if err != nil { 44 | return 45 | } 46 | go func(c net.Conn) { 47 | defer c.Close() 48 | // Handshake 49 | sConn, err := HandshakeAndUpgrade(c, cfg, table) 50 | if err != nil { 51 | t.Errorf("Server handshake failed: %v", err) 52 | return 53 | } 54 | defer sConn.Close() 55 | 56 | // Read Target Address (Standard Mode) 57 | target, _, _, err := protocol.ReadAddress(sConn) 58 | if err != nil { 59 | t.Errorf("Server read address failed: %v", err) 60 | return 61 | } 62 | if target != "example.com:80" { 63 | t.Errorf("Unexpected target: %s", target) 64 | return 65 | } 66 | 67 | // Echo Loop 68 | io.Copy(sConn, sConn) 69 | }(conn) 70 | } 71 | }() 72 | 73 | // 3. Client Logic (Dialer) 74 | dialer := &StandardDialer{ 75 | BaseDialer: BaseDialer{ 76 | Config: cfg, 77 | Tables: []*sudoku.Table{table}, 78 | }, 79 | } 80 | 81 | // Wait for server to be ready 82 | time.Sleep(100 * time.Millisecond) 83 | 84 | // 4. Connect 85 | conn, err := dialer.Dial("example.com:80") 86 | if err != nil { 87 | t.Fatalf("Dial failed: %v", err) 88 | } 89 | defer conn.Close() 90 | 91 | // 5. Verify Data Transfer 92 | message := "Hello, Sudoku!" 93 | var wg sync.WaitGroup 94 | wg.Add(1) 95 | 96 | go func() { 97 | defer wg.Done() 98 | buf := make([]byte, 1024) 99 | n, err := conn.Read(buf) 100 | if err != nil { 101 | t.Errorf("Client read failed: %v", err) 102 | return 103 | } 104 | received := string(buf[:n]) 105 | if received != message { 106 | t.Errorf("Client received wrong message: got %q, want %q", received, message) 107 | } 108 | }() 109 | 110 | _, err = conn.Write([]byte(message)) 111 | if err != nil { 112 | t.Fatalf("Client write failed: %v", err) 113 | } 114 | 115 | wg.Wait() 116 | } 117 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Cross-Platform Build & Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | build: 14 | name: Build 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | include: 19 | # Linux 20 | - goos: linux 21 | goarch: amd64 22 | asset_name: sudoku-linux-amd64 23 | - goos: linux 24 | goarch: arm64 25 | asset_name: sudoku-linux-arm64 26 | # Windows 27 | - goos: windows 28 | goarch: amd64 29 | asset_name: sudoku-windows-amd64 30 | extension: .exe 31 | # macOS 32 | - goos: darwin 33 | goarch: amd64 34 | asset_name: sudoku-darwin-amd64 35 | - goos: darwin 36 | goarch: arm64 37 | asset_name: sudoku-darwin-arm64 38 | # FreeBSD 39 | - goos: freebsd 40 | goarch: amd64 41 | asset_name: sudoku-freebsd-amd64 42 | 43 | steps: 44 | - name: Checkout code 45 | uses: actions/checkout@v4 46 | 47 | - name: Set up Go 48 | uses: actions/setup-go@v5 49 | with: 50 | go-version: '1.24' 51 | check-latest: true 52 | 53 | - name: Build Binary 54 | env: 55 | GOOS: ${{ matrix.goos }} 56 | GOARCH: ${{ matrix.goarch }} 57 | CGO_ENABLED: 0 58 | run: | 59 | OUTPUT_NAME=sudoku${{ matrix.extension }} 60 | go build -ldflags "-s -w" -o $OUTPUT_NAME ./cmd/sudoku-tunnel 61 | 62 | - name: Package Assets 63 | run: | 64 | BINARY_NAME=sudoku${{ matrix.extension }} 65 | ASSET_NAME=${{ matrix.asset_name }} 66 | 67 | cp configs/config.json config.json 68 | 69 | if [ "${{ matrix.goos }}" = "windows" ]; then 70 | zip ${ASSET_NAME}.zip ${BINARY_NAME} config.json 71 | echo "ARCHIVE_NAME=${ASSET_NAME}.zip" >> $GITHUB_ENV 72 | else 73 | tar -czvf ${ASSET_NAME}.tar.gz ${BINARY_NAME} config.json 74 | echo "ARCHIVE_NAME=${ASSET_NAME}.tar.gz" >> $GITHUB_ENV 75 | fi 76 | 77 | - name: Upload Artifact 78 | uses: actions/upload-artifact@v4 79 | with: 80 | name: artifacts-${{ matrix.asset_name }} 81 | path: ${{ env.ARCHIVE_NAME }} 82 | retention-days: 1 83 | 84 | release: 85 | name: Create Release 86 | needs: build 87 | runs-on: ubuntu-latest 88 | if: startsWith(github.ref, 'refs/tags/') 89 | steps: 90 | - name: Download all artifacts 91 | uses: actions/download-artifact@v4 92 | with: 93 | pattern: artifacts-* 94 | merge-multiple: true 95 | path: dist 96 | 97 | - name: Upload Release Assets 98 | uses: softprops/action-gh-release@v2 99 | with: 100 | files: dist/* 101 | env: 102 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /pkg/dnsutil/resolver_test.go: -------------------------------------------------------------------------------- 1 | package dnsutil 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "sync" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestResolve_IPLiteralBypassDNS(t *testing.T) { 13 | r := newResolver(1*time.Minute, func(ctx context.Context, network, host string) ([]net.IP, error) { 14 | t.Fatalf("DNS should not be called for IP literal") 15 | return nil, nil 16 | }) 17 | 18 | addr, err := r.Resolve(context.Background(), "1.2.3.4:80") 19 | if err != nil { 20 | t.Fatalf("unexpected error: %v", err) 21 | } 22 | if addr != "1.2.3.4:80" { 23 | t.Fatalf("unexpected addr: %s", addr) 24 | } 25 | } 26 | 27 | func TestResolve_CacheHitAvoidsDNS(t *testing.T) { 28 | var calls int 29 | lookup := func(ctx context.Context, network, host string) ([]net.IP, error) { 30 | calls++ 31 | return []net.IP{net.ParseIP("1.2.3.4")}, nil 32 | } 33 | 34 | r := newResolver(100*time.Millisecond, lookup) 35 | ctx := context.Background() 36 | 37 | addr1, err := r.Resolve(ctx, "example.com:80") 38 | if err != nil { 39 | t.Fatalf("resolve failed: %v", err) 40 | } 41 | if addr1 != "1.2.3.4:80" { 42 | t.Fatalf("unexpected addr1: %s", addr1) 43 | } 44 | 45 | addr2, err := r.Resolve(ctx, "example.com:80") 46 | if err != nil { 47 | t.Fatalf("second resolve failed: %v", err) 48 | } 49 | if addr2 != addr1 { 50 | t.Fatalf("cache mismatch: %s vs %s", addr1, addr2) 51 | } 52 | 53 | if calls == 0 { 54 | t.Fatalf("expected at least one DNS call") 55 | } 56 | } 57 | 58 | func TestResolve_OptimisticCacheOnFailure(t *testing.T) { 59 | ip := net.ParseIP("1.2.3.4") 60 | if ip == nil { 61 | t.Fatalf("failed to parse test IP") 62 | } 63 | 64 | var mu sync.Mutex 65 | fail := false 66 | 67 | lookup := func(ctx context.Context, network, host string) ([]net.IP, error) { 68 | mu.Lock() 69 | defer mu.Unlock() 70 | if fail { 71 | return nil, fmt.Errorf("dns failure") 72 | } 73 | if network == "ip4" { 74 | return []net.IP{ip}, nil 75 | } 76 | // Simulate missing IPv6 record. 77 | return nil, fmt.Errorf("no ipv6") 78 | } 79 | 80 | r := newResolver(20*time.Millisecond, lookup) 81 | ctx := context.Background() 82 | 83 | addr1, err := r.Resolve(ctx, "example.com:80") 84 | if err != nil { 85 | t.Fatalf("initial resolve failed: %v", err) 86 | } 87 | expected := "1.2.3.4:80" 88 | if addr1 != expected { 89 | t.Fatalf("unexpected addr1: %s", addr1) 90 | } 91 | 92 | // Expire the cache entry. 93 | time.Sleep(30 * time.Millisecond) 94 | 95 | // Force DNS failure; resolver should still return cached IP. 96 | mu.Lock() 97 | fail = true 98 | mu.Unlock() 99 | 100 | addr2, err := r.Resolve(ctx, "example.com:80") 101 | if err != nil { 102 | t.Fatalf("resolve with failing DNS should still succeed via optimistic cache: %v", err) 103 | } 104 | if addr2 != expected { 105 | t.Fatalf("unexpected addr2 with optimistic cache: %s", addr2) 106 | } 107 | } 108 | 109 | func TestResolve_InvalidAddress(t *testing.T) { 110 | r := newResolver(1*time.Minute, nil) 111 | if _, err := r.Resolve(context.Background(), "bad-address"); err == nil { 112 | t.Fatalf("expected error for invalid address") 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /tests/httpmask_tunnel_benchmark_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "testing" 7 | "time" 8 | 9 | "github.com/saba-futai/sudoku/apis" 10 | "github.com/saba-futai/sudoku/pkg/obfs/sudoku" 11 | ) 12 | 13 | func BenchmarkHTTPMaskTunnel_XHTTP(b *testing.B) { 14 | table := sudoku.NewTable("seed", "prefer_ascii") 15 | key := "bench-key-xhttp" 16 | 17 | serverCfg := &apis.ProtocolConfig{ 18 | Key: key, 19 | AEADMethod: "chacha20-poly1305", 20 | Table: table, 21 | PaddingMin: 0, 22 | PaddingMax: 0, 23 | EnablePureDownlink: true, 24 | HandshakeTimeoutSeconds: 5, 25 | DisableHTTPMask: false, 26 | HTTPMaskMode: "auto", 27 | } 28 | addr, stop := startHTTPMaskTunnelEchoServer(b, serverCfg) 29 | defer stop() 30 | 31 | clientCfg := &apis.ProtocolConfig{ 32 | ServerAddress: addr, 33 | TargetAddress: "example.com:80", 34 | Key: key, 35 | AEADMethod: "chacha20-poly1305", 36 | Table: table, 37 | PaddingMin: 0, 38 | PaddingMax: 0, 39 | EnablePureDownlink: true, 40 | DisableHTTPMask: false, 41 | HTTPMaskMode: "xhttp", 42 | } 43 | 44 | msg := []byte("ping") 45 | b.ResetTimer() 46 | for i := 0; i < b.N; i++ { 47 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 48 | conn, err := apis.Dial(ctx, clientCfg) 49 | if err != nil { 50 | cancel() 51 | b.Fatalf("dial: %v", err) 52 | } 53 | _, _ = conn.Write(msg) 54 | buf := make([]byte, len(msg)) 55 | _, _ = io.ReadFull(conn, buf) 56 | _ = conn.Close() 57 | cancel() 58 | } 59 | } 60 | 61 | func BenchmarkHTTPMaskTunnel_PHT(b *testing.B) { 62 | table := sudoku.NewTable("seed", "prefer_ascii") 63 | key := "bench-key-pht" 64 | 65 | serverCfg := &apis.ProtocolConfig{ 66 | Key: key, 67 | AEADMethod: "chacha20-poly1305", 68 | Table: table, 69 | PaddingMin: 0, 70 | PaddingMax: 0, 71 | EnablePureDownlink: true, 72 | HandshakeTimeoutSeconds: 5, 73 | DisableHTTPMask: false, 74 | HTTPMaskMode: "auto", 75 | } 76 | addr, stop := startHTTPMaskTunnelEchoServer(b, serverCfg) 77 | defer stop() 78 | 79 | clientCfg := &apis.ProtocolConfig{ 80 | ServerAddress: addr, 81 | TargetAddress: "example.com:80", 82 | Key: key, 83 | AEADMethod: "chacha20-poly1305", 84 | Table: table, 85 | PaddingMin: 0, 86 | PaddingMax: 0, 87 | EnablePureDownlink: true, 88 | DisableHTTPMask: false, 89 | HTTPMaskMode: "pht", 90 | } 91 | 92 | msg := []byte("ping") 93 | b.ResetTimer() 94 | for i := 0; i < b.N; i++ { 95 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 96 | conn, err := apis.Dial(ctx, clientCfg) 97 | if err != nil { 98 | cancel() 99 | b.Fatalf("dial: %v", err) 100 | } 101 | _, _ = conn.Write(msg) 102 | buf := make([]byte, len(msg)) 103 | _, _ = io.ReadFull(conn, buf) 104 | _ = conn.Close() 105 | cancel() 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /pkg/crypto/aead.go: -------------------------------------------------------------------------------- 1 | // pkg/crypto/aead.go 2 | package crypto 3 | 4 | import ( 5 | "bytes" 6 | "crypto/aes" 7 | "crypto/cipher" 8 | "crypto/rand" 9 | "crypto/sha256" 10 | "encoding/binary" 11 | "errors" 12 | "fmt" 13 | "io" 14 | "net" 15 | 16 | "golang.org/x/crypto/chacha20poly1305" 17 | ) 18 | 19 | type AEADConn struct { 20 | net.Conn 21 | aead cipher.AEAD 22 | readBuf bytes.Buffer 23 | nonceSize int 24 | } 25 | 26 | func NewAEADConn(c net.Conn, key string, method string) (*AEADConn, error) { 27 | if method == "none" { 28 | return &AEADConn{Conn: c, aead: nil}, nil 29 | } 30 | 31 | h := sha256.New() 32 | h.Write([]byte(key)) 33 | keyBytes := h.Sum(nil) 34 | 35 | var aead cipher.AEAD 36 | var err error 37 | 38 | switch method { 39 | case "aes-128-gcm": 40 | block, _ := aes.NewCipher(keyBytes[:16]) 41 | aead, err = cipher.NewGCM(block) 42 | case "chacha20-poly1305": 43 | aead, err = chacha20poly1305.New(keyBytes) 44 | default: 45 | return nil, fmt.Errorf("unsupported cipher: %s", method) 46 | } 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | return &AEADConn{ 52 | Conn: c, 53 | aead: aead, 54 | nonceSize: aead.NonceSize(), 55 | }, nil 56 | } 57 | 58 | func (cc *AEADConn) Write(p []byte) (int, error) { 59 | if cc.aead == nil { 60 | return cc.Conn.Write(p) 61 | } 62 | 63 | maxPayload := 65535 - cc.nonceSize - cc.aead.Overhead() 64 | totalWritten := 0 65 | var frameBuf bytes.Buffer 66 | header := make([]byte, 2) 67 | nonce := make([]byte, cc.nonceSize) 68 | 69 | for len(p) > 0 { 70 | chunkSize := len(p) 71 | if chunkSize > maxPayload { 72 | chunkSize = maxPayload 73 | } 74 | chunk := p[:chunkSize] 75 | p = p[chunkSize:] 76 | 77 | if _, err := io.ReadFull(rand.Reader, nonce); err != nil { 78 | return totalWritten, err 79 | } 80 | 81 | ciphertext := cc.aead.Seal(nil, nonce, chunk, nil) 82 | frameLen := len(nonce) + len(ciphertext) 83 | binary.BigEndian.PutUint16(header, uint16(frameLen)) 84 | 85 | frameBuf.Reset() 86 | frameBuf.Write(header) 87 | frameBuf.Write(nonce) 88 | frameBuf.Write(ciphertext) 89 | 90 | if _, err := cc.Conn.Write(frameBuf.Bytes()); err != nil { 91 | return totalWritten, err 92 | } 93 | totalWritten += chunkSize 94 | } 95 | return totalWritten, nil 96 | } 97 | 98 | func (cc *AEADConn) Read(p []byte) (int, error) { 99 | if cc.aead == nil { 100 | return cc.Conn.Read(p) 101 | } 102 | 103 | if cc.readBuf.Len() > 0 { 104 | return cc.readBuf.Read(p) 105 | } 106 | 107 | header := make([]byte, 2) 108 | if _, err := io.ReadFull(cc.Conn, header); err != nil { 109 | return 0, err 110 | } 111 | frameLen := int(binary.BigEndian.Uint16(header)) 112 | 113 | body := make([]byte, frameLen) 114 | if _, err := io.ReadFull(cc.Conn, body); err != nil { 115 | return 0, err 116 | } 117 | 118 | if len(body) < cc.nonceSize { 119 | return 0, errors.New("frame too short") 120 | } 121 | nonce := body[:cc.nonceSize] 122 | ciphertext := body[cc.nonceSize:] 123 | 124 | plaintext, err := cc.aead.Open(nil, nonce, ciphertext, nil) 125 | if err != nil { 126 | return 0, errors.New("decryption failed") 127 | } 128 | 129 | cc.readBuf.Write(plaintext) 130 | return cc.readBuf.Read(p) 131 | } 132 | -------------------------------------------------------------------------------- /pkg/crypto/ed25519.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "errors" 7 | "fmt" 8 | 9 | "filippo.io/edwards25519" 10 | ) 11 | 12 | // KeyPair holds the scalar private key and point public key 13 | type KeyPair struct { 14 | Private *edwards25519.Scalar 15 | Public *edwards25519.Point 16 | } 17 | 18 | // GenerateMasterKey generates a random master private key (scalar) and its public key (point) 19 | func GenerateMasterKey() (*KeyPair, error) { 20 | // 1. Generate random scalar x (32 bytes) 21 | var seed [64]byte 22 | if _, err := rand.Read(seed[:]); err != nil { 23 | return nil, err 24 | } 25 | 26 | x, err := edwards25519.NewScalar().SetUniformBytes(seed[:]) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | // 2. Calculate Public Key P = x * G 32 | P := new(edwards25519.Point).ScalarBaseMult(x) 33 | 34 | return &KeyPair{Private: x, Public: P}, nil 35 | } 36 | 37 | // SplitPrivateKey takes a master private key x and returns a new random split key (r, k) 38 | // such that x = r + k (mod L). 39 | // Returns hex encoded string of r || k (64 bytes) 40 | func SplitPrivateKey(x *edwards25519.Scalar) (string, error) { 41 | // 1. Generate random r (32 bytes) 42 | var seed [64]byte 43 | if _, err := rand.Read(seed[:]); err != nil { 44 | return "", err 45 | } 46 | r, err := edwards25519.NewScalar().SetUniformBytes(seed[:]) 47 | if err != nil { 48 | return "", err 49 | } 50 | 51 | // 2. Calculate k = x - r (mod L) 52 | k := new(edwards25519.Scalar).Subtract(x, r) 53 | 54 | // 3. Encode r and k 55 | rBytes := r.Bytes() 56 | kBytes := k.Bytes() 57 | 58 | full := make([]byte, 64) 59 | copy(full[:32], rBytes) 60 | copy(full[32:], kBytes) 61 | 62 | return hex.EncodeToString(full), nil 63 | } 64 | 65 | // RecoverPublicKey takes a split private key (r, k) or a master private key (x) 66 | // and returns the public key P. 67 | // Input can be: 68 | // - 32 bytes hex (Master Scalar x) 69 | // - 64 bytes hex (Split Key r || k) 70 | func RecoverPublicKey(keyHex string) (*edwards25519.Point, error) { 71 | keyBytes, err := hex.DecodeString(keyHex) 72 | if err != nil { 73 | return nil, fmt.Errorf("invalid hex: %w", err) 74 | } 75 | 76 | if len(keyBytes) == 32 { 77 | // Master Key x 78 | x, err := edwards25519.NewScalar().SetCanonicalBytes(keyBytes) 79 | if err != nil { 80 | return nil, fmt.Errorf("invalid scalar: %w", err) 81 | } 82 | return new(edwards25519.Point).ScalarBaseMult(x), nil 83 | 84 | } else if len(keyBytes) == 64 { 85 | // Split Key r || k 86 | rBytes := keyBytes[:32] 87 | kBytes := keyBytes[32:] 88 | 89 | r, err := edwards25519.NewScalar().SetCanonicalBytes(rBytes) 90 | if err != nil { 91 | return nil, fmt.Errorf("invalid scalar r: %w", err) 92 | } 93 | k, err := edwards25519.NewScalar().SetCanonicalBytes(kBytes) 94 | if err != nil { 95 | return nil, fmt.Errorf("invalid scalar k: %w", err) 96 | } 97 | 98 | // sum = r + k 99 | sum := new(edwards25519.Scalar).Add(r, k) 100 | 101 | // P = sum * G 102 | return new(edwards25519.Point).ScalarBaseMult(sum), nil 103 | } 104 | 105 | return nil, errors.New("invalid key length: must be 32 bytes (Master) or 64 bytes (Split)") 106 | } 107 | 108 | // EncodePoint returns the hex string of the compressed point 109 | func EncodePoint(p *edwards25519.Point) string { 110 | return hex.EncodeToString(p.Bytes()) 111 | } 112 | 113 | // EncodeScalar returns the hex string of the scalar 114 | func EncodeScalar(s *edwards25519.Scalar) string { 115 | return hex.EncodeToString(s.Bytes()) 116 | } 117 | -------------------------------------------------------------------------------- /tests/httpmask_switch_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net" 7 | "testing" 8 | 9 | "github.com/saba-futai/sudoku/apis" 10 | "github.com/saba-futai/sudoku/pkg/obfs/sudoku" 11 | ) 12 | 13 | func TestHTTPMaskSwitch(t *testing.T) { 14 | // Setup server 15 | serverListener, err := net.Listen("tcp", "127.0.0.1:0") 16 | if err != nil { 17 | t.Fatalf("failed to listen: %v", err) 18 | } 19 | defer serverListener.Close() 20 | 21 | serverAddr := serverListener.Addr().String() 22 | table := sudoku.NewTable("test-seed", "prefer_ascii") 23 | key := "test-key-123456" 24 | 25 | serverCfg := &apis.ProtocolConfig{ 26 | Key: key, 27 | AEADMethod: "chacha20-poly1305", 28 | Table: table, 29 | PaddingMin: 10, 30 | PaddingMax: 20, 31 | EnablePureDownlink: true, 32 | HandshakeTimeoutSeconds: 5, 33 | DisableHTTPMask: false, // Server enables mask (but should auto-detect) 34 | } 35 | 36 | go func() { 37 | for { 38 | conn, err := serverListener.Accept() 39 | if err != nil { 40 | return 41 | } 42 | go func(c net.Conn) { 43 | defer c.Close() 44 | tunnelConn, _, err := apis.ServerHandshake(c, serverCfg) 45 | if err != nil { 46 | // Handshake failed 47 | return 48 | } 49 | defer tunnelConn.Close() 50 | // Echo server 51 | io.Copy(tunnelConn, tunnelConn) 52 | }(conn) 53 | } 54 | }() 55 | 56 | // Test Case 1: Client with Mask (Default) 57 | t.Run("ClientWithMask", func(t *testing.T) { 58 | clientCfg := &apis.ProtocolConfig{ 59 | ServerAddress: serverAddr, 60 | TargetAddress: "example.com:80", 61 | Key: key, 62 | AEADMethod: "chacha20-poly1305", 63 | Table: table, 64 | PaddingMin: 10, 65 | PaddingMax: 20, 66 | EnablePureDownlink: true, 67 | DisableHTTPMask: false, 68 | } 69 | 70 | conn, err := apis.Dial(context.Background(), clientCfg) 71 | if err != nil { 72 | t.Fatalf("dial failed: %v", err) 73 | } 74 | defer conn.Close() 75 | 76 | msg := []byte("hello masked") 77 | if _, err := conn.Write(msg); err != nil { 78 | t.Fatalf("write failed: %v", err) 79 | } 80 | buf := make([]byte, len(msg)) 81 | if _, err := io.ReadFull(conn, buf); err != nil { 82 | t.Fatalf("read failed: %v", err) 83 | } 84 | if string(buf) != string(msg) { 85 | t.Fatalf("expected %s, got %s", msg, buf) 86 | } 87 | }) 88 | 89 | // Test Case 2: Client without Mask 90 | t.Run("ClientWithoutMask", func(t *testing.T) { 91 | clientCfg := &apis.ProtocolConfig{ 92 | ServerAddress: serverAddr, 93 | TargetAddress: "example.com:80", 94 | Key: key, 95 | AEADMethod: "chacha20-poly1305", 96 | Table: table, 97 | PaddingMin: 10, 98 | PaddingMax: 20, 99 | EnablePureDownlink: true, 100 | DisableHTTPMask: true, 101 | } 102 | 103 | conn, err := apis.Dial(context.Background(), clientCfg) 104 | if err != nil { 105 | t.Fatalf("dial failed: %v", err) 106 | } 107 | defer conn.Close() 108 | 109 | msg := []byte("hello unmasked") 110 | if _, err := conn.Write(msg); err != nil { 111 | t.Fatalf("write failed: %v", err) 112 | } 113 | buf := make([]byte, len(msg)) 114 | if _, err := io.ReadFull(conn, buf); err != nil { 115 | t.Fatalf("read failed: %v", err) 116 | } 117 | if string(buf) != string(msg) { 118 | t.Fatalf("expected %s, got %s", msg, buf) 119 | } 120 | }) 121 | } 122 | -------------------------------------------------------------------------------- /assets/logo-brutal.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | SUDOKU // ASCII 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 1 38 | ? 39 | 3 40 | 4 41 | 42 | 3 43 | 4 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 1 54 | 55 | 56 | 57 | 58 | 59 | > func Solve(b) { 60 | > if valid return 61 | > backtrack()... 62 | > }_ 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | SUDOKU.GO 71 | 72 | -------------------------------------------------------------------------------- /doc/getting-started.zh.md: -------------------------------------------------------------------------------- 1 | # Sudoku Tunnel 零基础上手指南 2 | 3 | 适合从未接触过代理/隧道的新手,带你从下载到验证一路跑通。 4 | 5 | ## 0. 服务端一键脚本 6 | 7 | **[easy-install](https://github.com/SUDOKU-ASCII/easy-install)** 8 | 9 | 10 | ## 1. 准备工作 11 | - 一台可被客户端访问到的服务器(有公网 IP / 域名,或在同一网络环境可直连)。 12 | - 电脑:Linux / macOS / Windows 均可。 13 | - 依赖:已下载发布版二进制,或安装 Go 1.22+ 准备自行编译。 14 | - 端口:服务端需要一个外网可访问的 TCP 端口(示例用 8080),客户端本地代理端口默认 1080;同时确认服务器防火墙/安全组已放行该端口。 15 | 16 | ## 2. 获取程序 17 | 二选一: 18 | 1) 直接下载:从 GitHub Releases 页面获取与你平台匹配的压缩包并解压出 `sudoku` 可执行文件。 19 | 2) 自行编译: 20 | ```bash 21 | git clone https://github.com/saba-futai/sudoku.git 22 | cd sudoku 23 | go build -o sudoku ./cmd/sudoku-tunnel 24 | ``` 25 | 26 | ## 3. 生成密钥 27 | ```bash 28 | ./sudoku -keygen 29 | ``` 30 | 输出中: 31 | - `Master Public Key`:填到服务端配置里的 `key`。 32 | - `Available Private Key`:填到客户端配置里的 `key`。 33 | - 需要更多私钥对应同一个公钥时,使用 `./sudoku -keygen -more `。 34 | 35 | ## 4. 准备服务端配置(server.json) 36 | 将以下内容保存为 `server.json`(按需修改端口和回落地址): 37 | ```json 38 | { 39 | "mode": "server", 40 | "local_port": 8080, 41 | "fallback_address": "127.0.0.1:80", 42 | "key": "粘贴 Master Public Key", 43 | "aead": "chacha20-poly1305", 44 | "suspicious_action": "fallback", 45 | "padding_min": 5, 46 | "padding_max": 15, 47 | "custom_table": "xpxvvpvv", 48 | "ascii": "prefer_entropy", 49 | "enable_pure_downlink": true 50 | } 51 | ``` 52 | 提示:如果你没有在 `fallback_address` 上准备诱饵网页服务,可以把 `"suspicious_action"` 设为 `"silent"`,对可疑连接直接丢弃。 53 | 54 | ## 5. 准备客户端配置(client.json) 55 | 将以下内容保存为 `client.json`,把 `server_address` 改成你的服务器地址和端口,把 `key` 换成 Available Private Key: 56 | ```json 57 | { 58 | "mode": "client", 59 | "local_port": 1080, 60 | "server_address": "1.2.3.4:8080", 61 | "key": "粘贴 Available Private Key", 62 | "aead": "chacha20-poly1305", 63 | "padding_min": 5, 64 | "padding_max": 15, 65 | "custom_table": "xpxvvpvv", 66 | "ascii": "prefer_entropy", 67 | "disable_http_mask": false, 68 | "rule_urls": ["global"] 69 | } 70 | ``` 71 | - 想要看起来更像纯文本:把 `ascii` 改成 `prefer_ascii`,客户端和服务端需一致。 72 | - 想要自定义字节指纹:添加 `custom_table`(两个 `x`、两个 `p`、四个 `v`,如 `xpxvvpvv`,共 420 种全排列);若同时配置 ASCII,则 ASCII 优先生效。 73 | - 想要更好的下行带宽:两端都将 `enable_pure_downlink` 设为 `false`,开启带宽优化下行(需 AEAD)。 74 | - 分流提示:`rule_urls: ["global"]` 表示全局代理(最省心)。如需 PAC 分流,请配置规则 URL(见 `doc/README.md`),或直接用短链启动(`./sudoku -link ...`)。 75 | 76 | ## 5.1(可选)过 Cloudflare CDN(小黄云) 77 | 如需走 Cloudflare CDN/反代,请使用真实 HTTP 隧道模式(`xhttp` / `pht` / `auto`),不要用 `legacy`。 78 | 79 | - 服务端:`"disable_http_mask": false`,并将 `"http_mask_mode"` 设为 `"pht"`(或 `"auto"`)。 80 | - 客户端:同样开启 HTTP mask,并把 `"server_address"` 填成 Cloudflare 域名(例如 `"your.domain.com:443"`;也可用 Cloudflare 支持的 `8080`/`8443` 等端口)。 81 | - `443` 会自动走 HTTPS;如需强制 HTTPS,可设 `"http_mask_tls": true`。 82 | 83 | ## 6. 启动 84 | ```bash 85 | # 服务端 86 | ./sudoku -c server.json 87 | 88 | # 客户端(本机开启 HTTP/SOCKS5 混合代理,默认 1080 端口) 89 | ./sudoku -c client.json 90 | ``` 91 | 92 | ## 7. 验证是否成功 93 | - 终端测试:`curl -x socks5h://127.0.0.1:1080 https://ipinfo.io/ip` 应返回服务器的出口 IP。 94 | - 浏览器:在代理插件或系统网络里填上 SOCKS5 `127.0.0.1` 端口 `1080`,访问网页确认可用。 95 | 96 | ## 8. 使用/导出短链 97 | - 启动客户端并直接用短链:`./sudoku -link "sudoku://..."`。 98 | - 从配置导出短链(分享给别人): 99 | - 客户端配置:`./sudoku -c client.json -export-link` 100 | - 服务端配置:`./sudoku -c server.json -export-link -public-host 域名[:端口]` 101 | 短链可让对方免编辑配置,直接运行即可。 102 | 提示:短链接支持 `custom_table` 以及 `custom_tables`(多表轮换),并可携带 CDN 相关的 HTTP mask 选项;如需兼容旧版本客户端,请至少保留 `custom_table`。 103 | 104 | ## 9. 常见问题速查 105 | - **端口占用**:更换 `local_port` 或释放冲突程序。 106 | - **握手失败/403**:确认客户端 `key` 与服务端公钥匹配;确保双方 `ascii`、`aead` 设置一致。 107 | - **连得上但很慢**:检查 `padding_min/max` 是否设置过大;确认服务器出口带宽与防火墙放行。 108 | - **配置是否生效**:使用 `-test` 选项,例如 `./sudoku -c server.json -test`,仅校验配置不真正启动。 109 | 110 | ## 10. 后台运行与更新 111 | - Linux 持久化:可参考 `doc/README.md` 里的 systemd 示例编写服务。 112 | - 更新:替换二进制后重启进程;如密钥未变,无需改配置。 113 | - 想快速重新生成配置:尝试 `./sudoku -tui`,按提示一步步选择,会自动生成并启动。 114 | -------------------------------------------------------------------------------- /internal/tunnel/buffered_conn_test.go: -------------------------------------------------------------------------------- 1 | package tunnel 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "io" 7 | "net" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | // mockConn is a simple mock net.Conn for testing 13 | type mockConn struct { 14 | net.Conn 15 | readBuf *bytes.Buffer 16 | writeBuf *bytes.Buffer 17 | } 18 | 19 | func newMockConn(data []byte) *mockConn { 20 | return &mockConn{ 21 | readBuf: bytes.NewBuffer(data), 22 | writeBuf: new(bytes.Buffer), 23 | } 24 | } 25 | 26 | func (m *mockConn) Read(b []byte) (n int, err error) { 27 | return m.readBuf.Read(b) 28 | } 29 | 30 | func (m *mockConn) Write(b []byte) (n int, err error) { 31 | return m.writeBuf.Write(b) 32 | } 33 | 34 | func (m *mockConn) Close() error { 35 | return nil 36 | } 37 | 38 | func (m *mockConn) LocalAddr() net.Addr { 39 | return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 8080} 40 | } 41 | 42 | func (m *mockConn) RemoteAddr() net.Addr { 43 | return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 54321} 44 | } 45 | 46 | func (m *mockConn) SetDeadline(t time.Time) error { 47 | return nil 48 | } 49 | 50 | func (m *mockConn) SetReadDeadline(t time.Time) error { 51 | return nil 52 | } 53 | 54 | func (m *mockConn) SetWriteDeadline(t time.Time) error { 55 | return nil 56 | } 57 | 58 | func TestBufferedConn_GetBufferedAndRecorded(t *testing.T) { 59 | testData := []byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n") 60 | mockConn := newMockConn(testData) 61 | 62 | // Create BufferedConn with recording enabled 63 | bc := &BufferedConn{ 64 | Conn: mockConn, 65 | r: bufio.NewReader(mockConn), 66 | recorder: new(bytes.Buffer), 67 | } 68 | 69 | // Read some data 70 | buf := make([]byte, 10) 71 | n, err := bc.Read(buf) 72 | if err != nil { 73 | t.Fatalf("Read failed: %v", err) 74 | } 75 | if n != 10 { 76 | t.Fatalf("Expected to read 10 bytes, got %d", n) 77 | } 78 | 79 | // Verify recorded data 80 | recorded := bc.GetBufferedAndRecorded() 81 | if len(recorded) < 10 { 82 | t.Errorf("Expected at least 10 bytes recorded, got %d", len(recorded)) 83 | } 84 | 85 | // The first 10 bytes should match what we read 86 | if !bytes.Equal(recorded[:10], testData[:10]) { 87 | t.Errorf("Recorded data mismatch: got %q, want %q", recorded[:10], testData[:10]) 88 | } 89 | 90 | // The remaining data should be buffered (peeked) 91 | if len(recorded) > 10 { 92 | // This is expected - bufio.Reader buffers ahead 93 | t.Logf("Total recorded+buffered: %d bytes", len(recorded)) 94 | } 95 | } 96 | 97 | func TestBufferedConn_GetBufferedAndRecorded_NoRecorder(t *testing.T) { 98 | testData := []byte("Some data") 99 | mockConn := newMockConn(testData) 100 | 101 | // Create BufferedConn WITHOUT recording 102 | bc := &BufferedConn{ 103 | Conn: mockConn, 104 | r: bufio.NewReader(mockConn), 105 | // recorder is nil 106 | } 107 | 108 | // Read some data 109 | buf := make([]byte, 5) 110 | _, err := bc.Read(buf) 111 | if err != nil { 112 | t.Fatalf("Read failed: %v", err) 113 | } 114 | 115 | // GetBufferedAndRecorded should still work, but only return buffered data 116 | recorded := bc.GetBufferedAndRecorded() 117 | // Should have at least the remaining buffered data 118 | if len(recorded) == 0 { 119 | // This is acceptable - if bufio hasn't buffered ahead, nothing to return 120 | t.Log("No data recorded (recorder was nil)") 121 | } 122 | } 123 | 124 | func TestBufferedConn_GetBufferedAndRecorded_AfterFullRead(t *testing.T) { 125 | testData := []byte("Short") 126 | mockConn := newMockConn(testData) 127 | 128 | bc := &BufferedConn{ 129 | Conn: mockConn, 130 | r: bufio.NewReader(mockConn), 131 | recorder: new(bytes.Buffer), 132 | } 133 | 134 | // Read all data 135 | buf := make([]byte, 100) 136 | n, err := bc.Read(buf) 137 | if err != nil && err != io.EOF { 138 | t.Fatalf("Read failed: %v", err) 139 | } 140 | 141 | // Verify all data was recorded 142 | recorded := bc.GetBufferedAndRecorded() 143 | if !bytes.Equal(recorded, testData[:n]) { 144 | t.Errorf("Recorded data mismatch: got %q, want %q", recorded, testData[:n]) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /internal/app/server.go: -------------------------------------------------------------------------------- 1 | // internal/app/server.go 2 | package app 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | "log" 8 | "net" 9 | "strings" 10 | "time" 11 | 12 | "github.com/saba-futai/sudoku/internal/config" 13 | "github.com/saba-futai/sudoku/internal/handler" 14 | "github.com/saba-futai/sudoku/internal/protocol" 15 | "github.com/saba-futai/sudoku/internal/tunnel" 16 | "github.com/saba-futai/sudoku/pkg/obfs/httpmask" 17 | "github.com/saba-futai/sudoku/pkg/obfs/sudoku" 18 | ) 19 | 20 | func RunServer(cfg *config.Config, tables []*sudoku.Table) { 21 | // 1. 监听 TCP 端口 22 | l, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.LocalPort)) 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | log.Printf("Server on :%d (Fallback: %s)", cfg.LocalPort, cfg.FallbackAddr) 27 | 28 | var tunnelSrv *httpmask.TunnelServer 29 | if !cfg.DisableHTTPMask { 30 | switch strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) { 31 | case "xhttp", "pht", "auto": 32 | tunnelSrv = httpmask.NewTunnelServer(httpmask.TunnelServerOptions{ 33 | Mode: cfg.HTTPMaskMode, 34 | }) 35 | } 36 | } 37 | 38 | for { 39 | c, err := l.Accept() 40 | if err != nil { 41 | continue 42 | } 43 | go handleServerConn(c, cfg, tables, tunnelSrv) 44 | } 45 | } 46 | 47 | func handleServerConn(rawConn net.Conn, cfg *config.Config, tables []*sudoku.Table, tunnelSrv *httpmask.TunnelServer) { 48 | if tunnelSrv != nil { 49 | res, c, err := tunnelSrv.HandleConn(rawConn) 50 | if err != nil { 51 | log.Printf("[Server][HTTP] tunnel prelude failed: %v", err) 52 | rawConn.Close() 53 | return 54 | } 55 | switch res { 56 | case httpmask.HandleDone: 57 | return 58 | case httpmask.HandleStartTunnel: 59 | inner := *cfg 60 | inner.DisableHTTPMask = true 61 | handleSudokuServerConn(c, rawConn, &inner, tables, false) 62 | return 63 | case httpmask.HandlePassThrough: 64 | handleSudokuServerConn(c, rawConn, cfg, tables, true) 65 | return 66 | default: 67 | rawConn.Close() 68 | return 69 | } 70 | } 71 | 72 | handleSudokuServerConn(rawConn, rawConn, cfg, tables, true) 73 | } 74 | 75 | func handleSudokuServerConn(handshakeConn net.Conn, rawConn net.Conn, cfg *config.Config, tables []*sudoku.Table, allowFallback bool) { 76 | // Use Tunnel Abstraction for Handshake and Upgrade 77 | tunnelConn, err := tunnel.HandshakeAndUpgradeWithTables(handshakeConn, cfg, tables) 78 | if err != nil { 79 | if suspErr, ok := err.(*tunnel.SuspiciousError); ok { 80 | log.Printf("[Security] Suspicious connection: %v", suspErr.Err) 81 | // Only meaningful for direct TCP/legacy mask connections. 82 | if allowFallback { 83 | handler.HandleSuspicious(suspErr.Conn, rawConn, cfg) 84 | } else { 85 | rawConn.Close() 86 | } 87 | } else { 88 | log.Printf("[Server] Handshake failed: %v", err) 89 | rawConn.Close() 90 | } 91 | return 92 | } 93 | 94 | // ========================================== 95 | // 5. 连接目标地址 96 | // ========================================== 97 | 98 | // 判断是否为 UoT (UDP over TCP) 会话 99 | firstByte := make([]byte, 1) 100 | if _, err := io.ReadFull(tunnelConn, firstByte); err != nil { 101 | log.Printf("[Server] Failed to read first byte: %v", err) 102 | return 103 | } 104 | 105 | if firstByte[0] == tunnel.UoTMagicByte { 106 | if err := tunnel.HandleUoTServer(tunnelConn); err != nil { 107 | log.Printf("[Server][UoT] session ended: %v", err) 108 | } 109 | return 110 | } 111 | 112 | // 非 UoT:将预读的字节放回流中以兼容旧协议 113 | prefixedConn := tunnel.NewPreBufferedConn(tunnelConn, firstByte) 114 | 115 | // 从上行连接读取目标地址 116 | destAddrStr, _, _, err := protocol.ReadAddress(prefixedConn) 117 | if err != nil { 118 | log.Printf("[Server] Failed to read target address: %v", err) 119 | return 120 | } 121 | 122 | log.Printf("[Server] Connecting to %s", destAddrStr) 123 | 124 | target, err := net.DialTimeout("tcp", destAddrStr, 10*time.Second) 125 | if err != nil { 126 | log.Printf("[Server] Connect target failed: %v", err) 127 | return 128 | } 129 | 130 | // ========================================== 131 | // 6. 转发数据 132 | // ========================================== 133 | pipeConn(prefixedConn, target) 134 | } 135 | -------------------------------------------------------------------------------- /apis/README.md: -------------------------------------------------------------------------------- 1 | # Sudoku API (Standard) 2 | 3 | 面向其他开发者开放的纯 Sudoku 协议 API:HTTP 伪装 + 数独 ASCII/Entropy 混淆 + AEAD 加密。支持带宽优化下行(`enable_pure_downlink=false`)与 UoT(UDP over TCP)。 4 | 5 | ## 安装 6 | - 推荐指定已有 tag:`go get github.com/saba-futai/sudoku@v0.1.0` 7 | - 或者直接跟随最新提交:`go get github.com/saba-futai/sudoku` 8 | 9 | ## 配置要点 10 | - 表格:`sudoku.NewTable("your-seed", "prefer_ascii"|"prefer_entropy")` 或 `sudoku.NewTableWithCustom("seed", "prefer_entropy", "xpxvvpvv")`(2 个 `x`、2 个 `p`、4 个 `v`,ASCII 优先)。 11 | - 密钥:任意字符串即可,需两端一致,可用 `./sudoku -keygen` 或 `crypto.GenerateMasterKey` 生成。 12 | - AEAD:`chacha20-poly1305`(默认)或 `aes-128-gcm`,`none` 仅测试用。 13 | - 填充:`PaddingMin`/`PaddingMax` 为 0-100 的概率百分比。 14 | - 客户端:设置 `ServerAddress`、`TargetAddress`。 15 | - 服务端:可设置 `HandshakeTimeoutSeconds` 限制握手耗时。 16 | 17 | ## 客户端示例 18 | ```go 19 | package main 20 | 21 | import ( 22 | "context" 23 | "log" 24 | "time" 25 | 26 | "github.com/saba-futai/sudoku/apis" 27 | "github.com/saba-futai/sudoku/pkg/obfs/sudoku" 28 | ) 29 | 30 | func main() { 31 | table, err := sudoku.NewTableWithCustom("seed-for-table", "prefer_entropy", "xpxvvpvv") 32 | if err != nil { 33 | log.Fatal(err) 34 | } 35 | 36 | cfg := &apis.ProtocolConfig{ 37 | ServerAddress: "1.2.3.4:8443", 38 | TargetAddress: "example.com:443", 39 | Key: "shared-key-hex-or-plain", 40 | AEADMethod: "chacha20-poly1305", 41 | Table: table, 42 | PaddingMin: 5, 43 | PaddingMax: 15, 44 | } 45 | 46 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 47 | defer cancel() 48 | 49 | conn, err := apis.Dial(ctx, cfg) 50 | if err != nil { 51 | log.Fatal(err) 52 | } 53 | defer conn.Close() 54 | 55 | // conn 即已完成握手的隧道,可直接读写应用层数据 56 | } 57 | ``` 58 | 59 | ## 服务端示例 60 | ```go 61 | package main 62 | 63 | import ( 64 | "io" 65 | "log" 66 | "net" 67 | 68 | "github.com/saba-futai/sudoku/apis" 69 | "github.com/saba-futai/sudoku/pkg/obfs/sudoku" 70 | ) 71 | 72 | func main() { 73 | table, err := sudoku.NewTableWithCustom("seed-for-table", "prefer_entropy", "xpxvvpvv") 74 | if err != nil { 75 | log.Fatal(err) 76 | } 77 | // For rotation, build multiple tables and set cfg.Tables instead of cfg.Table. 78 | 79 | cfg := &apis.ProtocolConfig{ 80 | Key: "shared-key-hex-or-plain", 81 | AEADMethod: "chacha20-poly1305", 82 | Table: table, 83 | PaddingMin: 5, 84 | PaddingMax: 15, 85 | HandshakeTimeoutSeconds: 5, 86 | } 87 | 88 | ln, err := net.Listen("tcp", ":8080") 89 | if err != nil { 90 | log.Fatal(err) 91 | } 92 | for { 93 | rawConn, err := ln.Accept() 94 | if err != nil { 95 | log.Println("accept:", err) 96 | continue 97 | } 98 | go func(c net.Conn) { 99 | defer c.Close() 100 | 101 | tunnel, target, err := apis.ServerHandshake(c, cfg) 102 | if err != nil { 103 | // 握手失败时可按需 fallback;HandshakeError 携带已读数据 104 | log.Println("handshake:", err) 105 | return 106 | } 107 | defer tunnel.Close() 108 | 109 | up, err := net.Dial("tcp", target) 110 | if err != nil { 111 | log.Println("dial target:", err) 112 | return 113 | } 114 | defer up.Close() 115 | 116 | go io.Copy(up, tunnel) 117 | io.Copy(tunnel, up) 118 | }(rawConn) 119 | } 120 | } 121 | ``` 122 | 123 | ## CDN/代理模式(xhttp / pht) 124 | 如需通过 CDN(例如 Cloudflare 小黄云)转发到服务端,设置 `cfg.DisableHTTPMask=false` 且 `cfg.HTTPMaskMode="auto"`(或 `"xhttp"` / `"pht"`),并在 accept 后使用 `apis.NewHTTPMaskTunnelServer(cfg).HandleConn`: 125 | 126 | ```go 127 | srv := apis.NewHTTPMaskTunnelServer(cfg) 128 | for { 129 | rawConn, _ := ln.Accept() 130 | go func(c net.Conn) { 131 | defer c.Close() 132 | tunnel, target, handled, err := srv.HandleConn(c) 133 | if err != nil || !handled || tunnel == nil { 134 | return 135 | } 136 | defer tunnel.Close() 137 | _ = target 138 | io.Copy(tunnel, tunnel) 139 | }(rawConn) 140 | } 141 | ``` 142 | 143 | ## 说明 144 | - `DefaultConfig()` 提供合理默认值,仍需设置 `Key`、`Table` 及对应的地址字段。 145 | - 服务端如需回落(HTTP/原始 TCP),可从 `HandshakeError` 取出 `HTTPHeaderData` 与 `ReadData` 按顺序重放。 146 | - 带宽优化模式:将 `enable_pure_downlink` 设为 `false`,需启用 AEAD。 147 | - 如需 UoT,调用 `DialUDPOverTCP` / `DetectUoT` + `HandleUoT`。 148 | -------------------------------------------------------------------------------- /assets/logo-cute.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | SUDOKU 38 | SUDOKU 39 | 40 | ASCII 4x4 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 1 2 59 | 3 4 60 | 61 | 3 4 62 | 1 2 63 | 64 | 2 1 65 | 4 3 66 | 67 | 4 3 68 | 2 1 69 | 70 | 71 | -------------------------------------------------------------------------------- /pkg/obfs/sudoku/table.go: -------------------------------------------------------------------------------- 1 | // pkg/obfs/sudoku/table.go 2 | package sudoku 3 | 4 | import ( 5 | "crypto/sha256" 6 | "encoding/binary" 7 | "errors" 8 | "log" 9 | "math/rand" 10 | "time" 11 | ) 12 | 13 | var ( 14 | ErrInvalidSudokuMapMiss = errors.New("INVALID_SUDOKU_MAP_MISS") 15 | ) 16 | 17 | type Table struct { 18 | EncodeTable [256][][4]byte 19 | DecodeMap map[uint32]byte 20 | PaddingPool []byte 21 | IsASCII bool // 标记当前模式 22 | layout *byteLayout 23 | } 24 | 25 | // NewTable initializes the obfuscation tables with built-in layouts. 26 | // Equivalent to calling NewTableWithCustom(key, mode, ""). 27 | func NewTable(key string, mode string) *Table { 28 | t, err := NewTableWithCustom(key, mode, "") 29 | if err != nil { 30 | log.Panicf("failed to build table: %v", err) 31 | } 32 | return t 33 | } 34 | 35 | // NewTableWithCustom initializes obfuscation tables using either predefined or custom layouts. 36 | // mode: "prefer_ascii" or "prefer_entropy". If a custom pattern is provided, ASCII mode still takes precedence. 37 | // The customPattern must contain 8 characters with exactly 2 x, 2 p, and 4 v (case-insensitive). 38 | func NewTableWithCustom(key string, mode string, customPattern string) (*Table, error) { 39 | start := time.Now() 40 | 41 | layout, err := resolveLayout(mode, customPattern) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | t := &Table{ 47 | DecodeMap: make(map[uint32]byte), 48 | IsASCII: layout.name == "ascii", 49 | layout: layout, 50 | } 51 | t.PaddingPool = append(t.PaddingPool, layout.paddingPool...) 52 | 53 | // 生成数独网格 (逻辑不变) 54 | allGrids := GenerateAllGrids() 55 | h := sha256.New() 56 | h.Write([]byte(key)) 57 | seed := int64(binary.BigEndian.Uint64(h.Sum(nil)[:8])) 58 | rng := rand.New(rand.NewSource(seed)) 59 | 60 | shuffledGrids := make([]Grid, 288) 61 | copy(shuffledGrids, allGrids) 62 | rng.Shuffle(len(shuffledGrids), func(i, j int) { 63 | shuffledGrids[i], shuffledGrids[j] = shuffledGrids[j], shuffledGrids[i] 64 | }) 65 | 66 | // 预计算组合 67 | var combinations [][]int 68 | var combine func(int, int, []int) 69 | combine = func(s, k int, c []int) { 70 | if k == 0 { 71 | tmp := make([]int, len(c)) 72 | copy(tmp, c) 73 | combinations = append(combinations, tmp) 74 | return 75 | } 76 | for i := s; i <= 16-k; i++ { 77 | c = append(c, i) 78 | combine(i+1, k-1, c) 79 | c = c[:len(c)-1] 80 | } 81 | } 82 | combine(0, 4, []int{}) 83 | 84 | // 构建映射表 85 | for byteVal := 0; byteVal < 256; byteVal++ { 86 | targetGrid := shuffledGrids[byteVal] 87 | for _, positions := range combinations { 88 | var currentHints [4]byte 89 | 90 | // 1. 计算抽象提示 (Abstract Hints) 91 | // 我们先计算出 val 和 pos,后面再根据模式编码成 byte 92 | var rawParts [4]struct{ val, pos byte } 93 | 94 | for i, pos := range positions { 95 | val := targetGrid[pos] // 1..4 96 | rawParts[i] = struct{ val, pos byte }{val, uint8(pos)} 97 | } 98 | 99 | // 检查唯一性 (数独逻辑) 100 | matchCount := 0 101 | for _, g := range allGrids { 102 | match := true 103 | for _, p := range rawParts { 104 | if g[p.pos] != p.val { 105 | match = false 106 | break 107 | } 108 | } 109 | if match { 110 | matchCount++ 111 | if matchCount > 1 { 112 | break 113 | } 114 | } 115 | } 116 | 117 | if matchCount == 1 { 118 | // 唯一确定,生成最终编码字节 119 | for i, p := range rawParts { 120 | currentHints[i] = t.layout.encodeHint(p.val-1, p.pos) 121 | } 122 | 123 | t.EncodeTable[byteVal] = append(t.EncodeTable[byteVal], currentHints) 124 | // 生成解码键 (需要对 Hints 进行排序以忽略传输顺序) 125 | key := packHintsToKey(currentHints) 126 | t.DecodeMap[key] = byte(byteVal) 127 | } 128 | } 129 | } 130 | log.Printf("[Init] Sudoku Tables initialized (%s) in %v", layout.name, time.Since(start)) 131 | return t, nil 132 | } 133 | 134 | func packHintsToKey(hints [4]byte) uint32 { 135 | // Sorting network for 4 elements (Bubble sort unrolled) 136 | // Swap if a > b 137 | if hints[0] > hints[1] { 138 | hints[0], hints[1] = hints[1], hints[0] 139 | } 140 | if hints[2] > hints[3] { 141 | hints[2], hints[3] = hints[3], hints[2] 142 | } 143 | if hints[0] > hints[2] { 144 | hints[0], hints[2] = hints[2], hints[0] 145 | } 146 | if hints[1] > hints[3] { 147 | hints[1], hints[3] = hints[3], hints[1] 148 | } 149 | if hints[1] > hints[2] { 150 | hints[1], hints[2] = hints[2], hints[1] 151 | } 152 | 153 | return uint32(hints[0])<<24 | uint32(hints[1])<<16 | uint32(hints[2])<<8 | uint32(hints[3]) 154 | } 155 | -------------------------------------------------------------------------------- /internal/tunnel/uot.go: -------------------------------------------------------------------------------- 1 | package tunnel 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "fmt" 7 | "io" 8 | "net" 9 | "sync" 10 | 11 | "github.com/saba-futai/sudoku/internal/protocol" 12 | ) 13 | 14 | const ( 15 | // UoTMagicByte marks a Sudoku tunnel connection that carries UDP-over-TCP traffic. 16 | UoTMagicByte byte = 0xEE 17 | uotVersion = 0x01 18 | 19 | maxUoTPayload = 64 * 1024 20 | ) 21 | 22 | // UoTDialer extends Dialer with the ability to bootstrap a UDP-over-TCP tunnel. 23 | type UoTDialer interface { 24 | Dialer 25 | DialUDPOverTCP() (net.Conn, error) 26 | } 27 | 28 | // WriteUoTPreface writes the UDP-over-TCP marker and version. 29 | func WriteUoTPreface(w io.Writer) error { 30 | _, err := w.Write([]byte{UoTMagicByte, uotVersion}) 31 | return err 32 | } 33 | 34 | // WriteUoTDatagram sends a single UDP datagram frame over the reliable tunnel. 35 | func WriteUoTDatagram(w io.Writer, addr string, payload []byte) error { 36 | addrBuf := &bytes.Buffer{} 37 | if err := protocol.WriteAddress(addrBuf, addr); err != nil { 38 | return fmt.Errorf("encode address: %w", err) 39 | } 40 | 41 | if addrBuf.Len() > int(^uint16(0)) { 42 | return fmt.Errorf("address too long: %d", addrBuf.Len()) 43 | } 44 | if len(payload) > int(^uint16(0)) { 45 | return fmt.Errorf("payload too large: %d", len(payload)) 46 | } 47 | 48 | header := make([]byte, 4) 49 | binary.BigEndian.PutUint16(header[:2], uint16(addrBuf.Len())) 50 | binary.BigEndian.PutUint16(header[2:], uint16(len(payload))) 51 | 52 | if _, err := w.Write(header); err != nil { 53 | return err 54 | } 55 | if _, err := w.Write(addrBuf.Bytes()); err != nil { 56 | return err 57 | } 58 | _, err := w.Write(payload) 59 | return err 60 | } 61 | 62 | // ReadUoTDatagram parses a single UDP datagram frame from the reliable tunnel. 63 | func ReadUoTDatagram(r io.Reader) (string, []byte, error) { 64 | header := make([]byte, 4) 65 | if _, err := io.ReadFull(r, header); err != nil { 66 | return "", nil, err 67 | } 68 | 69 | addrLen := int(binary.BigEndian.Uint16(header[:2])) 70 | payloadLen := int(binary.BigEndian.Uint16(header[2:])) 71 | 72 | if addrLen <= 0 || addrLen > maxUoTPayload { 73 | return "", nil, fmt.Errorf("invalid address length: %d", addrLen) 74 | } 75 | if payloadLen < 0 || payloadLen > maxUoTPayload { 76 | return "", nil, fmt.Errorf("invalid payload length: %d", payloadLen) 77 | } 78 | 79 | addrBuf := make([]byte, addrLen) 80 | if _, err := io.ReadFull(r, addrBuf); err != nil { 81 | return "", nil, err 82 | } 83 | 84 | var addr string 85 | if parsed, _, _, err := protocol.ReadAddress(bytes.NewReader(addrBuf)); err != nil { 86 | return "", nil, fmt.Errorf("decode address: %w", err) 87 | } else { 88 | addr = parsed 89 | } 90 | 91 | payload := make([]byte, payloadLen) 92 | if _, err := io.ReadFull(r, payload); err != nil { 93 | return "", nil, err 94 | } 95 | 96 | return addr, payload, nil 97 | } 98 | 99 | // HandleUoTServer bridges UDP packets over the already-upgraded tunnel connection. 100 | func HandleUoTServer(conn net.Conn) error { 101 | versionBuf := make([]byte, 1) 102 | if _, err := io.ReadFull(conn, versionBuf); err != nil { 103 | return fmt.Errorf("read uot version: %w", err) 104 | } 105 | if versionBuf[0] != uotVersion { 106 | return fmt.Errorf("unsupported uot version: %d", versionBuf[0]) 107 | } 108 | 109 | pConn, err := net.ListenPacket("udp", "") 110 | if err != nil { 111 | return fmt.Errorf("listen udp for uot: %w", err) 112 | } 113 | 114 | errCh := make(chan error, 1) 115 | var once sync.Once 116 | 117 | closeAll := func(err error) { 118 | once.Do(func() { 119 | _ = conn.Close() 120 | _ = pConn.Close() 121 | errCh <- err 122 | }) 123 | } 124 | 125 | go func() { 126 | buf := make([]byte, maxUoTPayload) 127 | for { 128 | n, addr, err := pConn.ReadFrom(buf) 129 | if err != nil { 130 | closeAll(err) 131 | return 132 | } 133 | if err := WriteUoTDatagram(conn, addr.String(), buf[:n]); err != nil { 134 | closeAll(err) 135 | return 136 | } 137 | } 138 | }() 139 | 140 | go func() { 141 | for { 142 | addrStr, payload, err := ReadUoTDatagram(conn) 143 | if err != nil { 144 | closeAll(err) 145 | return 146 | } 147 | udpAddr, err := net.ResolveUDPAddr("udp", addrStr) 148 | if err != nil { 149 | // Skip invalid destinations instead of failing the whole session. 150 | continue 151 | } 152 | if _, err := pConn.WriteTo(payload, udpAddr); err != nil { 153 | closeAll(err) 154 | return 155 | } 156 | } 157 | }() 158 | 159 | return <-errCh 160 | } 161 | -------------------------------------------------------------------------------- /pkg/obfs/sudoku/custom_layout_test.go: -------------------------------------------------------------------------------- 1 | package sudoku 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "math/bits" 7 | "net" 8 | "testing" 9 | ) 10 | 11 | func TestCustomLayoutParsingAndPadding(t *testing.T) { 12 | table, err := NewTableWithCustom("seed-custom", "prefer_entropy", "xpxvvpvv") 13 | if err != nil { 14 | t.Fatalf("failed to build table: %v", err) 15 | } 16 | if table.IsASCII { 17 | t.Fatalf("custom table should not be marked ASCII") 18 | } 19 | if table.layout == nil || table.layout.hintMask == 0 { 20 | t.Fatalf("layout mask not initialized") 21 | } 22 | 23 | for _, b := range table.PaddingPool { 24 | if table.layout.isHint(b) { 25 | t.Fatalf("padding byte incorrectly recognized as hint: %08b", b) 26 | } 27 | if bits.OnesCount8(b) < 5 { 28 | t.Fatalf("padding hamming weight too low: %d", bits.OnesCount8(b)) 29 | } 30 | } 31 | } 32 | 33 | func TestCustomLayoutAsciiPriority(t *testing.T) { 34 | table, err := NewTableWithCustom("seed-custom", "prefer_ascii", "vpxxvpvv") 35 | if err != nil { 36 | t.Fatalf("failed to build ascii-preferred table: %v", err) 37 | } 38 | if !table.IsASCII { 39 | t.Fatalf("ascii preference should override custom pattern") 40 | } 41 | if table.layout.name != "ascii" { 42 | t.Fatalf("expected ascii layout, got %s", table.layout.name) 43 | } 44 | } 45 | 46 | func TestCustomLayoutConnRoundTrip(t *testing.T) { 47 | table, err := NewTableWithCustom("roundtrip", "prefer_entropy", "xpxvvpvv") 48 | if err != nil { 49 | t.Fatalf("table creation failed: %v", err) 50 | } 51 | 52 | c1, c2 := net.Pipe() 53 | defer c1.Close() 54 | defer c2.Close() 55 | 56 | writer := NewConn(c1, table, 0, 0, false) 57 | reader := NewConn(c2, table, 0, 0, false) 58 | 59 | payload := bytes.Repeat([]byte("sudoku-custom-layout"), 2048) 60 | done := make(chan error, 1) 61 | go func() { 62 | _, err := writer.Write(payload) 63 | done <- err 64 | }() 65 | 66 | buf := make([]byte, len(payload)) 67 | if _, err := io.ReadFull(reader, buf); err != nil { 68 | t.Fatalf("read failed: %v", err) 69 | } 70 | if err := <-done; err != nil { 71 | t.Fatalf("write failed: %v", err) 72 | } 73 | if !bytes.Equal(payload, buf) { 74 | t.Fatalf("payload mismatch") 75 | } 76 | } 77 | 78 | func TestCustomLayoutPackedRoundTrip(t *testing.T) { 79 | table, err := NewTableWithCustom("packed-roundtrip", "prefer_entropy", "xpxvvpvv") 80 | if err != nil { 81 | t.Fatalf("table creation failed: %v", err) 82 | } 83 | 84 | c1, c2 := net.Pipe() 85 | defer c1.Close() 86 | defer c2.Close() 87 | 88 | writer := NewPackedConn(c1, table, 0, 0) 89 | reader := NewPackedConn(c2, table, 0, 0) 90 | 91 | payload := bytes.Repeat([]byte{0xAB, 0xCD, 0xEF, 0x01}, 8192) 92 | done := make(chan error, 1) 93 | go func() { 94 | if _, err := writer.Write(payload); err != nil { 95 | done <- err 96 | return 97 | } 98 | done <- writer.Flush() 99 | }() 100 | 101 | buf := make([]byte, len(payload)) 102 | if _, err := io.ReadFull(reader, buf); err != nil { 103 | t.Fatalf("read failed: %v", err) 104 | } 105 | if err := <-done; err != nil { 106 | t.Fatalf("write/flush failed: %v", err) 107 | } 108 | if !bytes.Equal(payload, buf) { 109 | t.Fatalf("payload mismatch") 110 | } 111 | } 112 | 113 | func TestCustomLayoutInvalidPatterns(t *testing.T) { 114 | if _, err := NewTableWithCustom("seed", "prefer_entropy", "xxxxvvvv"); err == nil { 115 | t.Fatalf("expected error for invalid pattern") 116 | } 117 | if _, err := NewTableWithCustom("seed", "badmode", "xpxvvpvv"); err == nil { 118 | t.Fatalf("expected error for invalid ascii mode") 119 | } 120 | } 121 | 122 | func TestCustomLayoutPackedStress(t *testing.T) { 123 | table, err := NewTableWithCustom("stress-key", "prefer_entropy", "vxpvxvvp") 124 | if err != nil { 125 | t.Fatalf("table creation failed: %v", err) 126 | } 127 | 128 | c1, c2 := net.Pipe() 129 | defer c1.Close() 130 | defer c2.Close() 131 | 132 | writer := NewPackedConn(c1, table, 2, 4) 133 | reader := NewPackedConn(c2, table, 2, 4) 134 | 135 | payload := bytes.Repeat([]byte{0xFF, 0x00, 0x7F, 0x11, 0x22}, 20000) // ~100KB stress payload 136 | done := make(chan error, 1) 137 | go func() { 138 | if _, err := writer.Write(payload); err != nil { 139 | done <- err 140 | return 141 | } 142 | done <- writer.Flush() 143 | }() 144 | 145 | buf := make([]byte, len(payload)) 146 | if _, err := io.ReadFull(reader, buf); err != nil { 147 | t.Fatalf("read failed: %v", err) 148 | } 149 | if err := <-done; err != nil { 150 | t.Fatalf("write/flush failed: %v", err) 151 | } 152 | if !bytes.Equal(payload, buf) { 153 | t.Fatalf("stress payload mismatch") 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /doc/CHANGELOG.zh.md: -------------------------------------------------------------------------------- 1 | # 更新日志 2 | 3 | ## 版本概览 4 | - v0.0.7:移除旧的分离下行实现,新增 `enable_pure_downlink` 开关(默认纯数独下行,可关闭以启用 6bit 拆分下行并提升带宽);API/CLI 同步支持 UoT;改进 HTTP 伪装与回落。 5 | - v0.0.6:初版 Sudoku 混淆 + AEAD 加密 + HTTP 伪装,支持 PAC/HTTP/SOCKS 混合代理。 6 | - **v0.0.5**:新增 UoT(UDP over TCP)与 SOCKS5 UDP 支持,完善极端场景测试与 PR 自动化验证。 7 | - **v old)**:优化数独连接性能与资源管理(ab6f00b);补充文档入口(5890267)。 8 | - **v0.1.3(2025-11-25)**:CLI 增强支持拆分密钥生成;握手新增 SHA-256 鉴权与错误细分;Ed25519 推导与拆分;配置描述/指引优化。 9 | - **v0.1.2(2025-11-24)**:默认 Mieru 配置与修复初始化;连接缓冲/回放能力增强;HTTP 头处理与性能优化;SOCKS4、DNS 缓存、配置默认值加载与更多健壮性修复;新增标准模式测试。 10 | - **v0.1.1(2025-11-24)**:新增协议 API;修复缓冲接口的空指针风险。 11 | - **v0.0.ι(2025-11-24)**:HTTP 伪装与分离隧道支持;Mieru 下行隧道实现。 12 | - **v0.0.γ(2025-11-23)**:Mieru 分离隧道初版,完善文档。 13 | - **v0.0.α(2025-11-22)**:发布流程拆分;PAC 调试;YAML 规则、代理模式默认值与配置清理。 14 | - **v0.0.9 / v0.0.8 / v0.0.7 / v0.0.5 / v0.0.4 / v0.0.3 / v0.0.2 / v0.0.1**:核心 Sudoku ASCII 协议、SOCKS5+PAC,逐步加入 ASCII 模式、多协议混合代理与规则下载。 15 | 16 | ## 完整提交时间线 17 | - 2025-11-26 5890267 docs: add initial project README. 18 | - 2025-11-26 ab6f00b refactor(sudoku): 重构数独连接以提高性能和资源管理 19 | - 2025-11-25 7177bf1 (v0.1.3) feat(cli): enhance key generation with split key support 20 | - 2025-11-25 ba07aed feat(security): enhance handshake authentication with SHA-256 hashing 21 | - 2025-11-25 1677cb6 feat(config): update key generation instructions and improve mieru integration 22 | - 2025-11-25 a27b3d9 feat(crypto): implement Ed25519 key derivation and splitting 23 | - 2025-11-25 3b2c7c7 feat(api): enhance Sudoku protocol handshake with detailed error handling 24 | - 2025-11-25 fe8915e refactor(config): clarify ASCII mode description and optimize logic 25 | - 2025-11-24 7fec754 (v0.1.2) fix(config): correct mieru config initialization logic 26 | - 2025-11-24 ab3a69d feat(config): implement default mieru config when enabled but not set 27 | - 2025-11-24 5ff9eb4 feat(obfs): improve http header consumption and fallback handling 28 | - 2025-11-24 db26ba8 feat(tunnel): enhance BufferedConn with data recording and retrieval 29 | - 2025-11-24 7dee241 refactor(obfs/sudoku): reimplement connection management and buffering 30 | - 2025-11-24 ace9e6b fix(obfs/sudoku): add nil pointer checks to prevent panics 31 | - 2025-11-24 ee0e103 fix: Enhance connection safety and prevent panics with nil and type assertion checks across various connection types. 32 | - 2025-11-24 c7a28d7 perf: improve obfuscation performance by reducing allocations and adding benchmarks. 33 | - 2025-11-24 8e1c4cf feat: introduce configuration loading with default value application and remove specific HTTP masker content types. 34 | - 2025-11-24 6c19c88 feat: Add SOCKS4 proxy support, implement DNS caching, and include unit tests for protocol handlers. 35 | - 2025-11-24 716ac89 test: add `SudokuTunnel_Standard` test case for standard mode operation. 36 | - 2025-11-24 8bd57ec feat: Abstract client proxy connection logic with a new `tunnel.Dialer` interface, improve hybrid manager's connection 37 | - 2025-11-24 0cfc93a Antigravaty changed 38 | - 2025-11-24 b7d9b0b (v0.1.1) fix(obfs): handle nil pointer in GetBufferedAndRecorded method 39 | - 2025-11-24 c61b38e feat(api): implement Sudoku protocol client and server APIs 40 | - 2025-11-24 57b783e (v0.0.ι) feat(proxy): implement HTTP masking and split tunneling support 41 | - 2025-11-23 843b040 feat(hybrid): implement mieru-based downlink tunneling 42 | - 2025-11-23 9686484 (v0.0.γ) feat(hybrid): implement split tunneling with Mieru integration 43 | - 2025-11-22 806011e docs(readme): add link to Chinese documentation 44 | - 2025-11-22 45f5f07 docs(readme): refine documentation and clarify protocol features 45 | - 2025-11-22 b5a2c25 (v0.0.α) chore(release): split build and release workflows 46 | - 2025-11-22 2c831b1 debug(config): add pac proxy mode support 47 | - 2025-11-22 f61506f (v0.0.9) feat(geodata): support YAML format for rule parsing 48 | - 2025-11-22 14ee4d6 refactor(config): remove legacy geoip_url and update default proxy mode 49 | - 2025-11-22 65bc6a0 (v0.0.8) feat(client): implement mixed protocol proxy with HTTP/SOCKS5 support 50 | - 2025-11-22 45c5e81 feat(client): implement mixed protocol proxy with HTTP/SOCKS5 support 51 | - 2025-11-21 1f2130e docs(readme): translate and restructure documentation content 52 | - 2025-11-21 a87cf9e (v0.0.7) feat(obfs): implement ASCII mode for Sudoku obfuscation 53 | - 2025-11-21 9d3ac27 feat(obfs): implement ASCII mode for Sudoku obfuscation 54 | - 2025-11-21 fec2ad4 (v0.0.5) feat(obfs): implement ASCII mode for Sudoku obfuscation 55 | - 2025-11-21 8cb8d3a docs(readme): update README with badges, TODO section, and running instructions 56 | - 2025-11-21 5d40e57 (v0.0.4, v0.0.3) feat(proxy): implement SOCKS5 proxy with PAC routing support 57 | - 2025-11-20 aee2734 (v0.0.2, v0.0.1) feat(core): implement sudoku ascii traffic obfuscation protocol 58 | - 2025-11-20 067240f Initial commit -------------------------------------------------------------------------------- /doc/getting-started.en.md: -------------------------------------------------------------------------------- 1 | # Sudoku Tunnel Beginner Guide 2 | 3 | Step-by-step instructions for absolute beginners to get a working client/server pair. 4 | 5 | ## 0) One Click Script on Server 6 | 7 | **[easy-install](https://github.com/SUDOKU-ASCII/easy-install)** 8 | 9 | 10 | ## 1) What you need 11 | - A server with a public IP / domain (or otherwise reachable by the client). 12 | - OS: Linux / macOS / Windows. 13 | - Either download the release binary or install Go 1.22+ to build it yourself. 14 | - Ports: one public TCP port on the server (example: 8080), one local proxy port on the client (default 1080). Make sure the server port is open in firewall / security group. 15 | 16 | ## 2) Get the binary 17 | Pick one: 18 | 1) Download the prebuilt archive from GitHub Releases and extract the `sudoku` executable. 19 | 2) Build locally: 20 | ```bash 21 | git clone https://github.com/saba-futai/sudoku.git 22 | cd sudoku 23 | go build -o sudoku ./cmd/sudoku-tunnel 24 | ``` 25 | 26 | ## 3) Generate keys 27 | ```bash 28 | ./sudoku -keygen 29 | ``` 30 | - Put the `Master Public Key` into the server config `key`. 31 | - Put the `Available Private Key` into the client config `key`. 32 | - Need more private keys for the same public key? Run `./sudoku -keygen -more `. 33 | 34 | ## 4) Server config (`server.json`) 35 | ```json 36 | { 37 | "mode": "server", 38 | "local_port": 8080, 39 | "fallback_address": "127.0.0.1:80", 40 | "key": "Master Public Key here", 41 | "aead": "chacha20-poly1305", 42 | "suspicious_action": "fallback", 43 | "padding_min": 5, 44 | "padding_max": 15, 45 | "custom_table": "xpxvvpvv", 46 | "ascii": "prefer_entropy", 47 | "enable_pure_downlink": true 48 | } 49 | ``` 50 | Tip: if you don’t have a decoy web server on `fallback_address`, set `"suspicious_action": "silent"` to drop suspicious connections instead. 51 | 52 | ## 5) Client config (`client.json`) 53 | ```json 54 | { 55 | "mode": "client", 56 | "local_port": 1080, 57 | "server_address": "1.2.3.4:8080", 58 | "key": "Available Private Key here", 59 | "aead": "chacha20-poly1305", 60 | "padding_min": 5, 61 | "padding_max": 15, 62 | "custom_table": "xpxvvpvv", 63 | "ascii": "prefer_entropy", 64 | "disable_http_mask": false, 65 | "rule_urls": ["global"] 66 | } 67 | ``` 68 | - Want plaintext-looking traffic? Set `ascii` to `prefer_ascii` on both sides. 69 | - Need a custom byte fingerprint? Add `custom_table` (e.g. `xpxvvpvv` with two `x`, two `p`, four `v`; all 420 permutations are valid); ASCII mode still wins if enabled. 70 | - Want more downlink throughput? Set `enable_pure_downlink` to `false` on both sides to enable the packed mode (AEAD required). 71 | - Routing mode tip: `rule_urls: ["global"]` proxies everything (simplest). For PAC mode, provide rule URLs (see `doc/README.md`), or start from a short link (`./sudoku -link ...`). 72 | 73 | ## 5.1) Optional: Cloudflare CDN (orange cloud) 74 | To run through Cloudflare (or other CDN/reverse-proxy), use real HTTP tunnel modes (`xhttp` / `pht` / `auto`). Do not use `legacy`. 75 | 76 | - Server: set `"disable_http_mask": false` and `"http_mask_mode": "pht"` (or `"auto"`). 77 | - Client: same, and set `"server_address": "your.domain.com:443"` (or other Cloudflare-supported HTTP(S) ports like `8080`/`8443`). 78 | - Port `443` implies HTTPS automatically; to force HTTPS explicitly, set `"http_mask_tls": true`. 79 | 80 | ## 6) Run 81 | ```bash 82 | # Server 83 | ./sudoku -c server.json 84 | 85 | # Client (starts a mixed HTTP/SOCKS5 proxy on port 1080) 86 | ./sudoku -c client.json 87 | ``` 88 | 89 | ## 7) Verify it works 90 | - Terminal check: `curl -x socks5h://127.0.0.1:1080 https://ipinfo.io/ip` should show the server’s public IP. 91 | - Browser: set a SOCKS5 proxy to `127.0.0.1:1080` (or the port you chose) and browse the web. 92 | 93 | ## 8) Use or share a short link 94 | - Start the client directly from a link: `./sudoku -link "sudoku://..."`. 95 | - Export a link from your config to share: 96 | - client config: `./sudoku -c client.json -export-link` 97 | - server config: `./sudoku -c server.json -export-link -public-host host[:port]` 98 | - Tip: short links support `custom_table`, `custom_tables` rotation, and CDN-related HTTP mask options; keep `custom_table` if you need to support older clients. 99 | 100 | ## 9) Quick troubleshooting 101 | - Port in use: change `local_port` or free the port. 102 | - Handshake or 403 errors: verify the client `key` matches the server public key; ensure `ascii` and `aead` settings match. 103 | - Slow transfer: lower padding (`padding_min/max`) and confirm server bandwidth/firewall rules. 104 | - Validate configs without running: `./sudoku -c server.json -test`. 105 | 106 | ## 10) Run in the background and update 107 | - Linux persistence: see the systemd example in `doc/README.md`. 108 | - Upgrading: replace the binary and restart; configs stay the same if keys do not change. 109 | - Want an interactive setup? Try `./sudoku -tui` and follow the prompts. 110 | -------------------------------------------------------------------------------- /tests/api_uot_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net" 8 | "testing" 9 | "time" 10 | 11 | "github.com/saba-futai/sudoku/apis" 12 | "github.com/saba-futai/sudoku/pkg/obfs/sudoku" 13 | ) 14 | 15 | func TestAPIPackedDownlinkEcho(t *testing.T) { 16 | table := sudoku.NewTable("api-packed-seed", "prefer_ascii") 17 | cfg := &apis.ProtocolConfig{ 18 | ServerAddress: "", 19 | TargetAddress: "", 20 | Key: "api-packed-key", 21 | AEADMethod: "chacha20-poly1305", 22 | Table: table, 23 | PaddingMin: 8, 24 | PaddingMax: 16, 25 | EnablePureDownlink: false, 26 | HandshakeTimeoutSeconds: 5, 27 | DisableHTTPMask: false, 28 | } 29 | 30 | l, err := net.Listen("tcp", "127.0.0.1:0") 31 | if err != nil { 32 | t.Fatalf("listen failed: %v", err) 33 | } 34 | defer l.Close() 35 | addr := l.Addr().String() 36 | 37 | serverCfg := *cfg 38 | serverCfg.ServerAddress = addr 39 | serverCfg.TargetAddress = "" 40 | 41 | go func() { 42 | for { 43 | conn, err := l.Accept() 44 | if err != nil { 45 | return 46 | } 47 | go func(c net.Conn) { 48 | defer c.Close() 49 | tun, _, err := apis.ServerHandshake(c, &serverCfg) 50 | if err != nil { 51 | return 52 | } 53 | defer tun.Close() 54 | io.Copy(tun, tun) 55 | }(conn) 56 | } 57 | }() 58 | 59 | clientCfg := *cfg 60 | clientCfg.ServerAddress = addr 61 | clientCfg.TargetAddress = "example.com:80" 62 | 63 | conn, err := apis.Dial(context.Background(), &clientCfg) 64 | if err != nil { 65 | t.Fatalf("dial failed: %v", err) 66 | } 67 | defer conn.Close() 68 | 69 | msg := []byte("api packed downlink echo") 70 | if _, err := conn.Write(msg); err != nil { 71 | t.Fatalf("write failed: %v", err) 72 | } 73 | buf := make([]byte, len(msg)) 74 | if _, err := io.ReadFull(conn, buf); err != nil { 75 | t.Fatalf("read failed: %v", err) 76 | } 77 | if string(buf) != string(msg) { 78 | t.Fatalf("echo mismatch: %q vs %q", msg, buf) 79 | } 80 | } 81 | 82 | func TestAPIUoT(t *testing.T) { 83 | table := sudoku.NewTable("api-uot-seed", "prefer_entropy") 84 | cfg := &apis.ProtocolConfig{ 85 | Key: "api-uot-key", 86 | AEADMethod: "aes-128-gcm", 87 | Table: table, 88 | PaddingMin: 5, 89 | PaddingMax: 12, 90 | EnablePureDownlink: true, 91 | HandshakeTimeoutSeconds: 5, 92 | } 93 | 94 | l, err := net.Listen("tcp", "127.0.0.1:0") 95 | if err != nil { 96 | t.Fatalf("listen failed: %v", err) 97 | } 98 | defer l.Close() 99 | addr := l.Addr().String() 100 | 101 | udpEcho, udpPort, err := startUDPEchoServer() 102 | if err != nil { 103 | t.Fatalf("udp echo failed: %v", err) 104 | } 105 | defer udpEcho.Close() 106 | 107 | errCh := make(chan error, 4) 108 | 109 | go func() { 110 | for { 111 | conn, err := l.Accept() 112 | if err != nil { 113 | return 114 | } 115 | go func(c net.Conn) { 116 | defer c.Close() 117 | tun, fail, err := apis.ServerHandshakeFlexible(c, cfg) 118 | if err != nil { 119 | select { 120 | case errCh <- err: 121 | default: 122 | } 123 | return 124 | } 125 | isUoT, tuned, err := apis.DetectUoT(tun) 126 | if err != nil { 127 | select { 128 | case errCh <- err: 129 | default: 130 | } 131 | return 132 | } 133 | if !isUoT { 134 | select { 135 | case errCh <- fail(io.ErrUnexpectedEOF): 136 | default: 137 | } 138 | return 139 | } 140 | if err := apis.HandleUoT(tuned); err != nil { 141 | select { 142 | case errCh <- err: 143 | default: 144 | } 145 | } 146 | }(conn) 147 | } 148 | }() 149 | 150 | clientCfg := *cfg 151 | clientCfg.ServerAddress = addr 152 | clientCfg.TargetAddress = "0.0.0.0:0" // placeholder for validation 153 | 154 | t.Log("dialing UoT client") 155 | conn, err := apis.DialUDPOverTCP(context.Background(), &clientCfg) 156 | if err != nil { 157 | t.Fatalf("dial uot failed: %v", err) 158 | } 159 | defer conn.Close() 160 | 161 | target := net.JoinHostPort("127.0.0.1", fmt.Sprintf("%d", udpPort)) 162 | payload := []byte("api uot ping") 163 | 164 | t.Log("sending datagram") 165 | if err := apis.WriteUoTDatagram(conn, target, payload); err != nil { 166 | t.Fatalf("write uot datagram failed: %v", err) 167 | } 168 | conn.SetReadDeadline(time.Now().Add(5 * time.Second)) 169 | t.Log("waiting for response") 170 | addrStr, data, err := apis.ReadUoTDatagram(conn) 171 | if err != nil { 172 | t.Fatalf("read uot datagram failed: %v", err) 173 | } 174 | if addrStr != target { 175 | t.Fatalf("unexpected addr: %s", addrStr) 176 | } 177 | if string(data) != string(payload) { 178 | t.Fatalf("unexpected payload: %q", data) 179 | } 180 | 181 | select { 182 | case err := <-errCh: 183 | t.Fatalf("server side error: %v", err) 184 | default: 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /internal/app/client_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "net" 7 | "testing" 8 | "time" 9 | 10 | "github.com/saba-futai/sudoku/internal/config" 11 | "github.com/saba-futai/sudoku/pkg/obfs/sudoku" 12 | ) 13 | 14 | // MockConn implements net.Conn for testing 15 | type MockConn struct { 16 | ReadBuf *bytes.Buffer 17 | WriteBuf *bytes.Buffer 18 | Closed bool 19 | } 20 | 21 | func NewMockConn(data []byte) *MockConn { 22 | return &MockConn{ 23 | ReadBuf: bytes.NewBuffer(data), 24 | WriteBuf: new(bytes.Buffer), 25 | } 26 | } 27 | 28 | func (m *MockConn) Read(b []byte) (n int, err error) { 29 | return m.ReadBuf.Read(b) 30 | } 31 | 32 | func (m *MockConn) Write(b []byte) (n int, err error) { 33 | return m.WriteBuf.Write(b) 34 | } 35 | 36 | func (m *MockConn) Close() error { 37 | m.Closed = true 38 | return nil 39 | } 40 | 41 | func (m *MockConn) LocalAddr() net.Addr { return nil } 42 | func (m *MockConn) RemoteAddr() net.Addr { return nil } 43 | func (m *MockConn) SetDeadline(t time.Time) error { return nil } 44 | func (m *MockConn) SetReadDeadline(t time.Time) error { return nil } 45 | func (m *MockConn) SetWriteDeadline(t time.Time) error { return nil } 46 | 47 | // MockDialer implements tunnel.Dialer 48 | type MockDialer struct { 49 | DialFunc func(destAddrStr string) (net.Conn, error) 50 | } 51 | 52 | func (m *MockDialer) Dial(destAddrStr string) (net.Conn, error) { 53 | if m.DialFunc != nil { 54 | return m.DialFunc(destAddrStr) 55 | } 56 | return NewMockConn(nil), nil 57 | } 58 | 59 | func TestDNSCache(t *testing.T) { 60 | cache := &DNSCache{ 61 | cache: make(map[string]net.IP), 62 | ttl: 100 * time.Millisecond, 63 | } 64 | 65 | host := "example.com" 66 | ip := net.ParseIP("1.2.3.4") 67 | 68 | // Test Set & Get 69 | cache.Set(host, ip) 70 | got := cache.Lookup(host) 71 | if !got.Equal(ip) { 72 | t.Errorf("Cache lookup failed: got %v, want %v", got, ip) 73 | } 74 | 75 | // Test Expiration 76 | time.Sleep(150 * time.Millisecond) 77 | got = cache.Lookup(host) 78 | if got != nil { 79 | t.Errorf("Cache expiration failed: got %v, want nil", got) 80 | } 81 | } 82 | 83 | func TestHandleMixedConn_SOCKS4(t *testing.T) { 84 | // Construct SOCKS4 Connect Request 85 | // VN(4) | CD(1) | PORT(80) | IP(1.2.3.4) | USERID("user") | NULL 86 | buf := new(bytes.Buffer) 87 | buf.WriteByte(0x04) 88 | buf.WriteByte(0x01) 89 | binary.Write(buf, binary.BigEndian, uint16(80)) 90 | buf.Write([]byte{1, 2, 3, 4}) 91 | buf.WriteString("user") 92 | buf.WriteByte(0x00) 93 | 94 | conn := NewMockConn(buf.Bytes()) 95 | cfg := &config.Config{ProxyMode: "global"} 96 | table := sudoku.NewTable("key", "prefer_entropy") 97 | 98 | // Mock Dialer to capture target 99 | var target string 100 | dialer := &MockDialer{ 101 | DialFunc: func(destAddrStr string) (net.Conn, error) { 102 | target = destAddrStr 103 | return NewMockConn(nil), nil 104 | }, 105 | } 106 | 107 | handleMixedConn(conn, cfg, table, nil, dialer) 108 | 109 | // Verify Target 110 | expectedTarget := "1.2.3.4:80" 111 | if target != expectedTarget { 112 | t.Errorf("SOCKS4 target mismatch: got %q, want %q", target, expectedTarget) 113 | } 114 | 115 | // Verify Response (90 = Granted) 116 | // VN(0) | CD(90) | ... 117 | resp := conn.WriteBuf.Bytes() 118 | if len(resp) < 2 || resp[1] != 0x5A { 119 | t.Errorf("SOCKS4 response invalid: %v", resp) 120 | } 121 | } 122 | 123 | func TestHandleMixedConn_SOCKS5(t *testing.T) { 124 | // 1. Handshake: VER(5) | NMETHODS(1) | METHOD(0) 125 | // 2. Request: VER(5) | CMD(1) | RSV(0) | ATYP(1) | IP(1.2.3.4) | PORT(80) 126 | input := []byte{ 127 | 0x05, 0x01, 0x00, 128 | 0x05, 0x01, 0x00, 0x01, 1, 2, 3, 4, 0, 80, 129 | } 130 | conn := NewMockConn(input) 131 | cfg := &config.Config{ProxyMode: "global"} 132 | table := sudoku.NewTable("key", "prefer_entropy") 133 | 134 | var target string 135 | dialer := &MockDialer{ 136 | DialFunc: func(destAddrStr string) (net.Conn, error) { 137 | target = destAddrStr 138 | return NewMockConn(nil), nil 139 | }, 140 | } 141 | 142 | handleMixedConn(conn, cfg, table, nil, dialer) 143 | 144 | expectedTarget := "1.2.3.4:80" 145 | if target != expectedTarget { 146 | t.Errorf("SOCKS5 target mismatch: got %q, want %q", target, expectedTarget) 147 | } 148 | } 149 | 150 | func TestHandleMixedConn_HTTP(t *testing.T) { 151 | reqStr := "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n" 152 | conn := NewMockConn([]byte(reqStr)) 153 | cfg := &config.Config{ProxyMode: "global"} 154 | table := sudoku.NewTable("key", "prefer_entropy") 155 | 156 | var target string 157 | dialer := &MockDialer{ 158 | DialFunc: func(destAddrStr string) (net.Conn, error) { 159 | target = destAddrStr 160 | return NewMockConn(nil), nil 161 | }, 162 | } 163 | 164 | handleMixedConn(conn, cfg, table, nil, dialer) 165 | 166 | expectedTarget := "example.com:443" 167 | if target != expectedTarget { 168 | t.Errorf("HTTP target mismatch: got %q, want %q", target, expectedTarget) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /pkg/dnsutil/resolver.go: -------------------------------------------------------------------------------- 1 | // pkg/dnsutil/resolver.go 2 | package dnsutil 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "net" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | // lookupIPFunc abstracts DNS lookups for easier testing. 13 | type lookupIPFunc func(ctx context.Context, network, host string) ([]net.IP, error) 14 | 15 | type cacheEntry struct { 16 | ip net.IP 17 | expiresAt time.Time 18 | } 19 | 20 | type resolver struct { 21 | mu sync.RWMutex 22 | cache map[string]cacheEntry 23 | ttl time.Duration 24 | lookupFn lookupIPFunc 25 | } 26 | 27 | func newResolver(ttl time.Duration, fn lookupIPFunc) *resolver { 28 | if ttl <= 0 { 29 | ttl = 10 * time.Minute 30 | } 31 | if fn == nil { 32 | fn = func(ctx context.Context, network, host string) ([]net.IP, error) { 33 | return net.DefaultResolver.LookupIP(ctx, network, host) 34 | } 35 | } 36 | return &resolver{ 37 | cache: make(map[string]cacheEntry), 38 | ttl: ttl, 39 | lookupFn: fn, 40 | } 41 | } 42 | 43 | var defaultResolver = newResolver(10*time.Minute, nil) 44 | 45 | // ResolveWithCache resolves addr (host:port) into ip:port using 46 | // concurrent DNS lookups (IPv4/IPv6) and optimistic caching. 47 | // 48 | // Behavior: 49 | // - If host is already an IP, returns addr directly. 50 | // - If a fresh cache entry exists, returns it without DNS queries. 51 | // - If cache is stale and DNS fails, falls back to stale IP (optimistic cache). 52 | // - DNS lookups for IPv4/IPv6 are performed concurrently. 53 | func ResolveWithCache(ctx context.Context, addr string) (string, error) { 54 | return defaultResolver.Resolve(ctx, addr) 55 | } 56 | 57 | // Resolve performs the actual resolution logic on a resolver instance. 58 | func (r *resolver) Resolve(ctx context.Context, addr string) (string, error) { 59 | if addr == "" { 60 | return "", fmt.Errorf("empty address") 61 | } 62 | 63 | host, port, err := net.SplitHostPort(addr) 64 | if err != nil { 65 | return "", fmt.Errorf("invalid address %q: %w", addr, err) 66 | } 67 | 68 | // If already an IP literal, no DNS is needed. 69 | if ip := net.ParseIP(host); ip != nil { 70 | return addr, nil 71 | } 72 | 73 | now := time.Now() 74 | cachedIP, expired := r.lookup(host, now) 75 | 76 | // Fresh cache hit. 77 | if cachedIP != nil && !expired { 78 | return net.JoinHostPort(cachedIP.String(), port), nil 79 | } 80 | 81 | // Need DNS resolution (cache miss or expired). 82 | ips, err := r.lookupConcurrently(ctx, host) 83 | if err != nil { 84 | // Optimistic caching: fall back to stale IP if present. 85 | if cachedIP != nil { 86 | return net.JoinHostPort(cachedIP.String(), port), nil 87 | } 88 | return "", fmt.Errorf("dns lookup failed for %s: %w", host, err) 89 | } 90 | 91 | // Choose the first IP and update cache. 92 | selected := firstNonNilIP(ips) 93 | if selected == nil { 94 | if cachedIP != nil { 95 | // Should be rare, but still honor optimistic cache. 96 | return net.JoinHostPort(cachedIP.String(), port), nil 97 | } 98 | return "", fmt.Errorf("no usable ip found for host %s", host) 99 | } 100 | 101 | r.store(host, selected, now) 102 | return net.JoinHostPort(selected.String(), port), nil 103 | } 104 | 105 | func (r *resolver) lookup(host string, now time.Time) (net.IP, bool) { 106 | r.mu.RLock() 107 | entry, ok := r.cache[host] 108 | r.mu.RUnlock() 109 | if !ok { 110 | return nil, false 111 | } 112 | if now.After(entry.expiresAt) { 113 | return entry.ip, true 114 | } 115 | return entry.ip, false 116 | } 117 | 118 | func (r *resolver) store(host string, ip net.IP, now time.Time) { 119 | if ip == nil { 120 | return 121 | } 122 | r.mu.Lock() 123 | r.cache[host] = cacheEntry{ 124 | ip: append(net.IP(nil), ip...), // defensive copy 125 | expiresAt: now.Add(r.ttl), 126 | } 127 | r.mu.Unlock() 128 | } 129 | 130 | func (r *resolver) lookupConcurrently(ctx context.Context, host string) ([]net.IP, error) { 131 | type result struct { 132 | ips []net.IP 133 | err error 134 | } 135 | 136 | networks := []string{"ip4", "ip6"} 137 | ch := make(chan result, len(networks)) 138 | 139 | var wg sync.WaitGroup 140 | for _, network := range networks { 141 | network := network 142 | wg.Add(1) 143 | go func() { 144 | defer wg.Done() 145 | ips, err := r.lookupFn(ctx, network, host) 146 | select { 147 | case ch <- result{ips: ips, err: err}: 148 | case <-ctx.Done(): 149 | } 150 | }() 151 | } 152 | 153 | go func() { 154 | wg.Wait() 155 | close(ch) 156 | }() 157 | 158 | var allIPs []net.IP 159 | var firstErr error 160 | 161 | for res := range ch { 162 | if res.err == nil && len(res.ips) > 0 { 163 | allIPs = append(allIPs, res.ips...) 164 | } else if res.err != nil && firstErr == nil { 165 | firstErr = res.err 166 | } 167 | } 168 | 169 | if len(allIPs) == 0 { 170 | if firstErr == nil { 171 | firstErr = fmt.Errorf("no ip records found") 172 | } 173 | return nil, firstErr 174 | } 175 | 176 | return allIPs, nil 177 | } 178 | 179 | func firstNonNilIP(ips []net.IP) net.IP { 180 | for _, ip := range ips { 181 | if ip != nil { 182 | return ip 183 | } 184 | } 185 | return nil 186 | } 187 | -------------------------------------------------------------------------------- /pkg/obfs/sudoku/conn.go: -------------------------------------------------------------------------------- 1 | // pkg/obfs/sudoku/conn.go 2 | package sudoku 3 | 4 | import ( 5 | "bufio" 6 | "bytes" 7 | crypto_rand "crypto/rand" 8 | "encoding/binary" 9 | "errors" 10 | "math/rand" 11 | "net" 12 | "sync" 13 | ) 14 | 15 | const IOBufferSize = 32 * 1024 16 | 17 | var perm4 = [24][4]byte{ 18 | {0, 1, 2, 3}, 19 | {0, 1, 3, 2}, 20 | {0, 2, 1, 3}, 21 | {0, 2, 3, 1}, 22 | {0, 3, 1, 2}, 23 | {0, 3, 2, 1}, 24 | {1, 0, 2, 3}, 25 | {1, 0, 3, 2}, 26 | {1, 2, 0, 3}, 27 | {1, 2, 3, 0}, 28 | {1, 3, 0, 2}, 29 | {1, 3, 2, 0}, 30 | {2, 0, 1, 3}, 31 | {2, 0, 3, 1}, 32 | {2, 1, 0, 3}, 33 | {2, 1, 3, 0}, 34 | {2, 3, 0, 1}, 35 | {2, 3, 1, 0}, 36 | {3, 0, 1, 2}, 37 | {3, 0, 2, 1}, 38 | {3, 1, 0, 2}, 39 | {3, 1, 2, 0}, 40 | {3, 2, 0, 1}, 41 | {3, 2, 1, 0}, 42 | } 43 | 44 | type Conn struct { 45 | net.Conn 46 | table *Table 47 | reader *bufio.Reader 48 | recorder *bytes.Buffer 49 | recording bool 50 | recordLock sync.Mutex 51 | 52 | rawBuf []byte 53 | pendingData []byte 54 | hintBuf []byte 55 | 56 | rng *rand.Rand 57 | paddingRate float32 58 | } 59 | 60 | func NewConn(c net.Conn, table *Table, pMin, pMax int, record bool) *Conn { 61 | var seedBytes [8]byte 62 | if _, err := crypto_rand.Read(seedBytes[:]); err != nil { 63 | binary.BigEndian.PutUint64(seedBytes[:], uint64(rand.Int63())) 64 | } 65 | seed := int64(binary.BigEndian.Uint64(seedBytes[:])) 66 | localRng := rand.New(rand.NewSource(seed)) 67 | 68 | min := float32(pMin) / 100.0 69 | rng := float32(pMax-pMin) / 100.0 70 | rate := min + localRng.Float32()*rng 71 | 72 | sc := &Conn{ 73 | Conn: c, 74 | table: table, 75 | reader: bufio.NewReaderSize(c, IOBufferSize), 76 | rawBuf: make([]byte, IOBufferSize), 77 | pendingData: make([]byte, 0, 4096), 78 | hintBuf: make([]byte, 0, 4), 79 | rng: localRng, 80 | paddingRate: rate, 81 | } 82 | if record { 83 | sc.recorder = new(bytes.Buffer) 84 | sc.recording = true 85 | } 86 | return sc 87 | } 88 | 89 | func (sc *Conn) StopRecording() { 90 | sc.recordLock.Lock() 91 | sc.recording = false 92 | sc.recorder = nil 93 | sc.recordLock.Unlock() 94 | } 95 | 96 | func (sc *Conn) GetBufferedAndRecorded() []byte { 97 | if sc == nil { 98 | return nil 99 | } 100 | 101 | sc.recordLock.Lock() 102 | defer sc.recordLock.Unlock() 103 | 104 | var recorded []byte 105 | if sc.recorder != nil { 106 | recorded = sc.recorder.Bytes() 107 | } 108 | 109 | buffered := sc.reader.Buffered() 110 | if buffered > 0 { 111 | peeked, _ := sc.reader.Peek(buffered) 112 | full := make([]byte, len(recorded)+len(peeked)) 113 | copy(full, recorded) 114 | copy(full[len(recorded):], peeked) 115 | return full 116 | } 117 | return recorded 118 | } 119 | 120 | func (sc *Conn) Write(p []byte) (n int, err error) { 121 | if len(p) == 0 { 122 | return 0, nil 123 | } 124 | 125 | outCapacity := len(p) * 6 126 | out := make([]byte, 0, outCapacity) 127 | pads := sc.table.PaddingPool 128 | padLen := len(pads) 129 | 130 | for _, b := range p { 131 | if sc.rng.Float32() < sc.paddingRate { 132 | out = append(out, pads[sc.rng.Intn(padLen)]) 133 | } 134 | 135 | puzzles := sc.table.EncodeTable[b] 136 | puzzle := puzzles[sc.rng.Intn(len(puzzles))] 137 | 138 | perm := perm4[sc.rng.Intn(len(perm4))] 139 | for _, idx := range perm { 140 | if sc.rng.Float32() < sc.paddingRate { 141 | out = append(out, pads[sc.rng.Intn(padLen)]) 142 | } 143 | out = append(out, puzzle[idx]) 144 | } 145 | } 146 | 147 | if sc.rng.Float32() < sc.paddingRate { 148 | out = append(out, pads[sc.rng.Intn(padLen)]) 149 | } 150 | 151 | _, err = sc.Conn.Write(out) 152 | return len(p), err 153 | } 154 | 155 | func (sc *Conn) Read(p []byte) (n int, err error) { 156 | if len(sc.pendingData) > 0 { 157 | n = copy(p, sc.pendingData) 158 | if n == len(sc.pendingData) { 159 | sc.pendingData = sc.pendingData[:0] 160 | } else { 161 | sc.pendingData = sc.pendingData[n:] 162 | } 163 | return n, nil 164 | } 165 | 166 | for { 167 | if len(sc.pendingData) > 0 { 168 | break 169 | } 170 | 171 | nr, rErr := sc.reader.Read(sc.rawBuf) 172 | if nr > 0 { 173 | chunk := sc.rawBuf[:nr] 174 | sc.recordLock.Lock() 175 | if sc.recording { 176 | sc.recorder.Write(chunk) 177 | } 178 | sc.recordLock.Unlock() 179 | 180 | for _, b := range chunk { 181 | if !sc.table.layout.isHint(b) { 182 | continue 183 | } 184 | 185 | sc.hintBuf = append(sc.hintBuf, b) 186 | if len(sc.hintBuf) == 4 { 187 | key := packHintsToKey([4]byte{sc.hintBuf[0], sc.hintBuf[1], sc.hintBuf[2], sc.hintBuf[3]}) 188 | val, ok := sc.table.DecodeMap[key] 189 | if !ok { 190 | return 0, errors.New("INVALID_SUDOKU_MAP_MISS") 191 | } 192 | sc.pendingData = append(sc.pendingData, val) 193 | sc.hintBuf = sc.hintBuf[:0] 194 | } 195 | } 196 | } 197 | 198 | if rErr != nil { 199 | return 0, rErr 200 | } 201 | if len(sc.pendingData) > 0 { 202 | break 203 | } 204 | } 205 | 206 | n = copy(p, sc.pendingData) 207 | if n == len(sc.pendingData) { 208 | sc.pendingData = sc.pendingData[:0] 209 | } else { 210 | sc.pendingData = sc.pendingData[n:] 211 | } 212 | return n, nil 213 | } 214 | -------------------------------------------------------------------------------- /pkg/obfs/sudoku/layout.go: -------------------------------------------------------------------------------- 1 | package sudoku 2 | 3 | import ( 4 | "fmt" 5 | "math/bits" 6 | "sort" 7 | "strings" 8 | ) 9 | 10 | type byteLayout struct { 11 | name string 12 | hintMask byte 13 | hintValue byte 14 | padMarker byte 15 | paddingPool []byte 16 | 17 | encodeHint func(val, pos byte) byte 18 | encodeGroup func(group byte) byte 19 | decodeGroup func(b byte) (byte, bool) 20 | } 21 | 22 | func (l *byteLayout) isHint(b byte) bool { 23 | return (b & l.hintMask) == l.hintValue 24 | } 25 | 26 | // resolveLayout picks the byte layout based on ASCII preference and optional custom pattern. 27 | // ASCII always wins if requested. Custom patterns are ignored when ASCII is preferred. 28 | func resolveLayout(mode string, customPattern string) (*byteLayout, error) { 29 | switch strings.ToLower(mode) { 30 | case "ascii", "prefer_ascii": 31 | return newASCIILayout(), nil 32 | case "entropy", "prefer_entropy", "": 33 | // fallback to entropy unless a custom pattern is provided 34 | default: 35 | return nil, fmt.Errorf("invalid ascii mode: %s", mode) 36 | } 37 | 38 | if strings.TrimSpace(customPattern) != "" { 39 | return newCustomLayout(customPattern) 40 | } 41 | return newEntropyLayout(), nil 42 | } 43 | 44 | func newASCIILayout() *byteLayout { 45 | padding := make([]byte, 0, 32) 46 | for i := 0; i < 32; i++ { 47 | padding = append(padding, byte(0x20+i)) 48 | } 49 | return &byteLayout{ 50 | name: "ascii", 51 | hintMask: 0x40, 52 | hintValue: 0x40, 53 | padMarker: 0x3F, 54 | paddingPool: padding, 55 | encodeHint: func(val, pos byte) byte { 56 | return 0x40 | ((val & 0x03) << 4) | (pos & 0x0F) 57 | }, 58 | encodeGroup: func(group byte) byte { 59 | return 0x40 | (group & 0x3F) 60 | }, 61 | decodeGroup: func(b byte) (byte, bool) { 62 | if (b & 0x40) == 0 { 63 | return 0, false 64 | } 65 | return b & 0x3F, true 66 | }, 67 | } 68 | } 69 | 70 | func newEntropyLayout() *byteLayout { 71 | padding := make([]byte, 0, 16) 72 | for i := 0; i < 8; i++ { 73 | padding = append(padding, byte(0x80+i)) 74 | padding = append(padding, byte(0x10+i)) 75 | } 76 | return &byteLayout{ 77 | name: "entropy", 78 | hintMask: 0x90, 79 | hintValue: 0x00, 80 | padMarker: 0x80, 81 | paddingPool: padding, 82 | encodeHint: func(val, pos byte) byte { 83 | return ((val & 0x03) << 5) | (pos & 0x0F) 84 | }, 85 | encodeGroup: func(group byte) byte { 86 | v := group & 0x3F 87 | return ((v & 0x30) << 1) | (v & 0x0F) 88 | }, 89 | decodeGroup: func(b byte) (byte, bool) { 90 | if (b & 0x90) != 0 { 91 | return 0, false 92 | } 93 | return ((b >> 1) & 0x30) | (b & 0x0F), true 94 | }, 95 | } 96 | } 97 | 98 | func newCustomLayout(pattern string) (*byteLayout, error) { 99 | cleaned := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(pattern), " ", "")) 100 | if len(cleaned) != 8 { 101 | return nil, fmt.Errorf("custom table must have 8 symbols, got %d", len(cleaned)) 102 | } 103 | 104 | var xBits, pBits, vBits []uint8 105 | for i, c := range cleaned { 106 | bit := uint8(7 - i) 107 | switch c { 108 | case 'x': 109 | xBits = append(xBits, bit) 110 | case 'p': 111 | pBits = append(pBits, bit) 112 | case 'v': 113 | vBits = append(vBits, bit) 114 | default: 115 | return nil, fmt.Errorf("invalid char %q in custom table", c) 116 | } 117 | } 118 | 119 | if len(xBits) != 2 || len(pBits) != 2 || len(vBits) != 4 { 120 | return nil, fmt.Errorf("custom table must contain exactly 2 x, 2 p, 4 v") 121 | } 122 | 123 | xMask := byte(0) 124 | for _, b := range xBits { 125 | xMask |= 1 << b 126 | } 127 | 128 | encodeBits := func(val, pos byte, dropX int) byte { 129 | var out byte 130 | out |= xMask 131 | if dropX >= 0 { 132 | out &^= 1 << xBits[dropX] 133 | } 134 | if (val & 0x02) != 0 { 135 | out |= 1 << pBits[0] 136 | } 137 | if (val & 0x01) != 0 { 138 | out |= 1 << pBits[1] 139 | } 140 | for i, bit := range vBits { 141 | if (pos>>(3-uint8(i)))&0x01 == 1 { 142 | out |= 1 << bit 143 | } 144 | } 145 | return out 146 | } 147 | 148 | decodeGroup := func(b byte) (byte, bool) { 149 | if (b & xMask) != xMask { 150 | return 0, false 151 | } 152 | var val, pos byte 153 | if b&(1<= 5 { 175 | if _, ok := paddingSet[b]; !ok { 176 | paddingSet[b] = struct{}{} 177 | padding = append(padding, b) 178 | } 179 | } 180 | } 181 | } 182 | } 183 | sort.Slice(padding, func(i, j int) bool { return padding[i] < padding[j] }) 184 | if len(padding) == 0 { 185 | return nil, fmt.Errorf("custom table produced empty padding pool") 186 | } 187 | 188 | return &byteLayout{ 189 | name: fmt.Sprintf("custom(%s)", cleaned), 190 | hintMask: xMask, 191 | hintValue: xMask, 192 | padMarker: padding[0], 193 | paddingPool: padding, 194 | encodeHint: func(val, pos byte) byte { 195 | return encodeBits(val, pos, -1) 196 | }, 197 | encodeGroup: func(group byte) byte { 198 | val := (group >> 4) & 0x03 199 | pos := group & 0x0F 200 | return encodeBits(val, pos, -1) 201 | }, 202 | decodeGroup: decodeGroup, 203 | }, nil 204 | } 205 | -------------------------------------------------------------------------------- /internal/app/setup.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/saba-futai/sudoku/internal/config" 11 | "github.com/saba-futai/sudoku/pkg/crypto" 12 | ) 13 | 14 | // WizardResult aggregates outputs from the interactive setup. 15 | type WizardResult struct { 16 | ServerConfig *config.Config 17 | ClientConfig *config.Config 18 | ServerConfigPath string 19 | ClientConfigPath string 20 | ShortLink string 21 | } 22 | 23 | // RunSetupWizard builds server/client configs interactively and exports a short link. 24 | func RunSetupWizard(defaultServerPath, publicHost string) (*WizardResult, error) { 25 | reader := bufio.NewReader(os.Stdin) 26 | 27 | fmt.Println("== Sudoku Server Setup ==") 28 | host := promptString(reader, "Server public host/IP", publicHost, "127.0.0.1") 29 | serverPort := promptInt(reader, "Server port", 8080) 30 | mixPort := promptInt(reader, "Client mixed proxy port", 1080) 31 | fallback := promptString(reader, "Fallback address for suspicious traffic", "", "127.0.0.1:80") 32 | aead := promptString(reader, "AEAD (chacha20-poly1305 / aes-128-gcm / none)", "", "chacha20-poly1305") 33 | asciiMode := resolveASCII(promptString(reader, "Encoding (ascii / entropy)", "", "entropy")) 34 | suspiciousAction := promptString(reader, "Suspicious action (fallback / silent)", "", "fallback") 35 | paddingMin := promptInt(reader, "Padding min (%)", 5) 36 | paddingMax := promptInt(reader, "Padding max (%)", 15) 37 | customTable := promptString(reader, "Custom table layout (optional, e.g. xpxvvpvv)", "", "") 38 | if paddingMax < paddingMin { 39 | fmt.Printf("Padding max is smaller than min, using %d for both\n", paddingMin) 40 | paddingMax = paddingMin 41 | } 42 | pureDownlinkInput := strings.ToLower(strings.TrimSpace(promptString(reader, "Enable pure Sudoku downlink? (yes/no)", "yes", "yes"))) 43 | enablePureDownlink := pureDownlinkInput != "no" && pureDownlinkInput != "n" 44 | if !enablePureDownlink && aead == "none" { 45 | fmt.Println("Bandwidth-optimized downlink requires AEAD. Forcing chacha20-poly1305.") 46 | aead = "chacha20-poly1305" 47 | } 48 | 49 | keyInput := promptString(reader, "Shared key (leave empty to auto-generate)", "", "") 50 | key := strings.TrimSpace(keyInput) 51 | if key == "" { 52 | // Use public key as the shared secret to avoid accidental private key exposure. 53 | pair, err := crypto.GenerateMasterKey() 54 | if err != nil { 55 | return nil, fmt.Errorf("generate key failed: %w", err) 56 | } 57 | key = crypto.EncodePoint(pair.Public) 58 | fmt.Printf("Generated shared key: %s\n", key) 59 | } 60 | 61 | serverCfg := &config.Config{ 62 | Mode: "server", 63 | Transport: "tcp", 64 | LocalPort: serverPort, 65 | FallbackAddr: fallback, 66 | Key: key, 67 | AEAD: aead, 68 | SuspiciousAction: suspiciousAction, 69 | PaddingMin: paddingMin, 70 | PaddingMax: paddingMax, 71 | ASCII: asciiMode, 72 | CustomTable: customTable, 73 | EnablePureDownlink: enablePureDownlink, 74 | } 75 | 76 | clientCfg := &config.Config{ 77 | Mode: "client", 78 | Transport: "tcp", 79 | LocalPort: mixPort, 80 | ServerAddress: fmt.Sprintf("%s:%d", host, serverPort), 81 | Key: key, 82 | AEAD: aead, 83 | PaddingMin: paddingMin, 84 | PaddingMax: paddingMax, 85 | ASCII: asciiMode, 86 | CustomTable: customTable, 87 | ProxyMode: "pac", 88 | RuleURLs: nil, 89 | EnablePureDownlink: enablePureDownlink, 90 | } 91 | 92 | serverPath := promptString(reader, "Server config output path", defaultServerPath, defaultServerPath) 93 | if serverPath == "" { 94 | serverPath = "config.server.json" 95 | } 96 | clientPath := promptString(reader, "Client config output path", "client.config.json", "client.config.json") 97 | if clientPath == "" { 98 | clientPath = "client.config.json" 99 | } 100 | 101 | if err := config.Save(serverPath, serverCfg); err != nil { 102 | return nil, fmt.Errorf("save server config: %w", err) 103 | } 104 | if err := config.Save(clientPath, clientCfg); err != nil { 105 | return nil, fmt.Errorf("save client config: %w", err) 106 | } 107 | 108 | shortLink, err := config.BuildShortLinkFromConfig(clientCfg, "") 109 | if err != nil { 110 | return nil, fmt.Errorf("build short link: %w", err) 111 | } 112 | 113 | return &WizardResult{ 114 | ServerConfig: serverCfg, 115 | ClientConfig: clientCfg, 116 | ServerConfigPath: serverPath, 117 | ClientConfigPath: clientPath, 118 | ShortLink: shortLink, 119 | }, nil 120 | } 121 | 122 | func promptString(r *bufio.Reader, label, current, fallback string) string { 123 | displayDefault := current 124 | if displayDefault == "" { 125 | displayDefault = fallback 126 | } 127 | fmt.Printf("%s [%s]: ", label, displayDefault) 128 | line, _ := r.ReadString('\n') 129 | line = strings.TrimSpace(line) 130 | if line == "" { 131 | return displayDefault 132 | } 133 | return line 134 | } 135 | 136 | func promptInt(r *bufio.Reader, label string, def int) int { 137 | fmt.Printf("%s [%d]: ", label, def) 138 | line, _ := r.ReadString('\n') 139 | line = strings.TrimSpace(line) 140 | if line == "" { 141 | return def 142 | } 143 | val, err := strconv.Atoi(line) 144 | if err != nil { 145 | fmt.Printf("Invalid number, using %d\n", def) 146 | return def 147 | } 148 | return val 149 | } 150 | 151 | func resolveASCII(val string) string { 152 | switch strings.ToLower(strings.TrimSpace(val)) { 153 | case "ascii", "prefer_ascii": 154 | return "prefer_ascii" 155 | default: 156 | return "prefer_entropy" 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /cmd/sudoku-tunnel/main.go: -------------------------------------------------------------------------------- 1 | // cmd/sudoku-tunnel/main.go 2 | package main 3 | 4 | import ( 5 | "encoding/hex" 6 | "flag" 7 | "fmt" 8 | "log" 9 | "os" 10 | "strings" 11 | 12 | "filippo.io/edwards25519" 13 | "github.com/saba-futai/sudoku/internal/app" 14 | "github.com/saba-futai/sudoku/internal/config" 15 | "github.com/saba-futai/sudoku/pkg/crypto" 16 | "github.com/saba-futai/sudoku/pkg/obfs/sudoku" 17 | ) 18 | 19 | var ( 20 | configPath = flag.String("c", "config.json", "Path to configuration file") 21 | testConfig = flag.Bool("test", false, "Test configuration file and exit") 22 | keygen = flag.Bool("keygen", false, "Generate a new Ed25519 key pair") 23 | more = flag.String("more", "", "Generate more Private key (hex) for split key generations") 24 | linkInput = flag.String("link", "", "Start client directly from a sudoku:// short link") 25 | exportLink = flag.Bool("export-link", false, "Print sudoku:// short link generated from the config") 26 | publicHost = flag.String("public-host", "", "Advertised server host for short link generation (server mode); supports host or host:port") 27 | setupWizard = flag.Bool("tui", false, "Launch interactive TUI to create config before starting") 28 | ) 29 | 30 | func main() { 31 | flag.Parse() 32 | 33 | if *keygen { 34 | if *more != "" { 35 | 36 | // 1. Decode input 37 | keyBytes, err := hex.DecodeString(*more) 38 | if err != nil { 39 | log.Fatalf("Invalid private key hex: %v", err) 40 | } 41 | 42 | var x *edwards25519.Scalar 43 | if len(keyBytes) == 32 { 44 | x, err = edwards25519.NewScalar().SetCanonicalBytes(keyBytes) 45 | if err != nil { 46 | log.Fatalf("Invalid scalar: %v", err) 47 | } 48 | } else if len(keyBytes) == 64 { 49 | // Recover x from r, k 50 | r, err := edwards25519.NewScalar().SetCanonicalBytes(keyBytes[:32]) 51 | if err != nil { 52 | log.Fatalf("Invalid scalar r: %v", err) 53 | } 54 | k, err := edwards25519.NewScalar().SetCanonicalBytes(keyBytes[32:]) 55 | if err != nil { 56 | log.Fatalf("Invalid scalar k: %v", err) 57 | } 58 | x = new(edwards25519.Scalar).Add(r, k) 59 | } else { 60 | log.Fatal("Invalid key length. Must be 32 bytes (Master) or 64 bytes (Split)") 61 | } 62 | 63 | // 2. Generate new split key 64 | splitKey, err := crypto.SplitPrivateKey(x) 65 | if err != nil { 66 | log.Fatalf("Failed to split key: %v", err) 67 | } 68 | fmt.Printf("Split Private Key: %s\n", splitKey) 69 | return 70 | } 71 | 72 | // Generate new Master Key 73 | pair, err := crypto.GenerateMasterKey() 74 | if err != nil { 75 | log.Fatalf("Failed to generate key: %v", err) 76 | } 77 | keyBytes, err := hex.DecodeString(crypto.EncodeScalar(pair.Private)) 78 | 79 | x, err := edwards25519.NewScalar().SetCanonicalBytes(keyBytes) 80 | splitKey, err := crypto.SplitPrivateKey(x) 81 | if err != nil { 82 | log.Fatalf("Failed to generate key: %v", err) 83 | } 84 | fmt.Printf("Available Private Key: %s\n", splitKey) 85 | fmt.Printf("Master Private Key: %s\n", crypto.EncodeScalar(pair.Private)) 86 | fmt.Printf("Master Public Key: %s\n", crypto.EncodePoint(pair.Public)) 87 | return 88 | } 89 | 90 | if *linkInput != "" { 91 | cfg, err := config.BuildConfigFromShortLink(*linkInput) 92 | if err != nil { 93 | log.Fatalf("Failed to parse short link: %v", err) 94 | } 95 | tables, err := buildTables(cfg.Key, cfg.ASCII, cfg.CustomTable, cfg.CustomTables) 96 | if err != nil { 97 | log.Fatalf("Failed to build table: %v", err) 98 | } 99 | app.RunClient(cfg, tables) 100 | return 101 | } 102 | 103 | if *setupWizard { 104 | result, err := app.RunSetupWizard(*configPath, *publicHost) 105 | if err != nil { 106 | log.Fatalf("Setup failed: %v", err) 107 | } 108 | fmt.Printf("Server config saved to %s\n", result.ServerConfigPath) 109 | fmt.Printf("Client config saved to %s\n", result.ClientConfigPath) 110 | fmt.Printf("Short link: %s\n", result.ShortLink) 111 | 112 | tables, err := buildTables(result.ServerConfig.Key, result.ServerConfig.ASCII, result.ServerConfig.CustomTable, result.ServerConfig.CustomTables) 113 | if err != nil { 114 | log.Fatalf("Failed to build table: %v", err) 115 | } 116 | app.RunServer(result.ServerConfig, tables) 117 | return 118 | } 119 | 120 | cfg, err := config.Load(*configPath) 121 | if err != nil { 122 | log.Fatalf("Failed to load config from %s: %v", *configPath, err) 123 | } 124 | 125 | if *testConfig { 126 | fmt.Printf("Configuration %s is valid.\n", *configPath) 127 | fmt.Printf("Mode: %s\n", cfg.Mode) 128 | if cfg.Mode == "client" { 129 | fmt.Printf("Rules: %d URLs configured\n", len(cfg.RuleURLs)) 130 | } 131 | os.Exit(0) 132 | } 133 | 134 | if *exportLink { 135 | link, err := config.BuildShortLinkFromConfig(cfg, *publicHost) 136 | if err != nil { 137 | log.Fatalf("Export short link failed: %v", err) 138 | } 139 | fmt.Printf("Short link: %s\n", link) 140 | os.Exit(0) 141 | } 142 | 143 | tables, err := buildTables(cfg.Key, cfg.ASCII, cfg.CustomTable, cfg.CustomTables) 144 | if err != nil { 145 | log.Fatalf("Failed to build table: %v", err) 146 | } 147 | 148 | if cfg.Mode == "client" { 149 | app.RunClient(cfg, tables) 150 | } else { 151 | app.RunServer(cfg, tables) 152 | } 153 | } 154 | 155 | func buildTables(key string, ascii string, customTable string, customTables []string) ([]*sudoku.Table, error) { 156 | patterns := customTables 157 | if len(patterns) == 0 && strings.TrimSpace(customTable) != "" { 158 | patterns = []string{customTable} 159 | } 160 | if len(patterns) == 0 { 161 | patterns = []string{""} 162 | } 163 | tables := make([]*sudoku.Table, 0, len(patterns)) 164 | for _, p := range patterns { 165 | t, err := sudoku.NewTableWithCustom(key, ascii, p) 166 | if err != nil { 167 | return nil, err 168 | } 169 | tables = append(tables, t) 170 | } 171 | return tables, nil 172 | } 173 | -------------------------------------------------------------------------------- /README.zh_CN.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 一种抛弃随机数基于数独的代理协议,开启了明文 / 低熵 / 用户自定义特征代理时代 4 |

5 | 6 | # Sudoku (ASCII) 7 | 8 | > Sudoku 协议目前已被 [Mihomo](https://github.com/MetaCubeX/mihomo) 内核支持! 9 | 10 | [![构建状态](https://img.shields.io/github/actions/workflow/status/saba-futai/sudoku/.github/workflows/release.yml?branch=main&style=for-the-badge)](https://github.com/saba-futai/sudoku/actions) 11 | [![最新版本](https://img.shields.io/github/v/release/saba-futai/sudoku?style=for-the-badge)](https://github.com/saba-futai/sudoku/releases) 12 | [![License](https://img.shields.io/badge/License-GPL%20v3-blue.svg?style=for-the-badge)](./LICENSE) 13 | 14 | **SUDOKU** 是一个基于4x4数独设题解题的流量混淆协议。它通过将任意数据流(数据字节最多有256种可能,4x4数独的非同构体有288种)映射为以4个Clue为题目的唯一可解数独谜题,每种Puzzle有不少于一种的设题方案,随机选择的过程使得同一数据编码后有多种组合,产生了混淆性。 15 | 16 | > **以防有些人看不懂README的中文,特此澄清**:在enable_pure_downlink false时下行带宽利用率在**80%**,而非网传的30%,并且下行不是随机数,上行是什么字节格式,下行就是什么,你有422种选择。请不要诋毁sudoku了可以吗,这又不是什么利益竞争。有问题提issue。 17 | 18 | 该项目的核心理念是利用数独网格的数学特性,实现对字节流的编解码,同时提供任意填充与抗主动探测能力。 19 | 20 | ## 安卓客户端 & 服务器一键脚本: 21 | 22 | **[Sudodroid](https://github.com/saba-futai/sudoku-android)** 23 | **[easy-install](https://github.com/SUDOKU-ASCII/easy-install)** 24 | 25 | 26 | ## 核心特性 27 | 28 | ### 数独隐写算法 29 | 不同于传统的随机噪音混淆,本协议通过多种掩码方案,可以将数据流映射到完整的ASCII可打印字符(这只是微不足道的、可选的一种罢了,你的特征你来决定)中,抓包来看是完全的明文数据(特指在这种情况下,而非全部情况下,sudoku不是专为明文而生,明文只是附带的一个选择罢了),亦或者利用其他掩码方案,使得数据流的熵足够低。 30 | * **动态填充**: 在任意时刻任意位置填充任意长度非数据字节,隐藏协议特征。 31 | * **数据隐藏**: 填充字节的分布特征与明文字节分布特征基本一致(65%~100%*的ASCII占比),可避免通过数据分布特征识别明文。 32 | * **低信息熵**: 整体字节汉明重量约在3.0/5.0*(低熵模式下),低于GFW Report提到的会被阻断的3.4~4.6。 33 | * **自由暖暖**: 用户可以随意定义想要的字节样式,我们不推荐某一种,正是大家混着用才能更好规避审查。 34 | 35 | --- 36 | 37 | > *注:100%的ASCII占比须在ASCII优先模式下,ENTROPY优先模式下为65%。 3.0的汉明重量须在ENTROPY优先模式下,ASCII优先模式下为4.0。目前没有证据表明任一优先策略有明显指纹。 38 | 39 | ### 下行模式 40 | * **纯 Sudoku 下行**:默认模式,上下行都使用经典的数独谜题编码。 41 | * **带宽优化下行**:将 `enable_pure_downlink` 设为 `false` 后,下行会把 AEAD 密文拆成 6bit 片段,复用原有的填充池与 ASCII/entropy/customised 偏好,降低下行开销;上行保持sudoku本身协议,下行特征此时与上行保持一致。此模式必须开启 AEAD。 42 | 43 | 44 | ### 安全与加密 45 | 在混淆层之下,协议可选的采用 AEAD 保护数据完整性与机密性。 46 | * **算法支持**: AES-128-GCM 或 ChaCha20-Poly1305。 47 | * **防重放**: 握手阶段包含时间戳校验,有效防止重放攻击。 48 | 49 | ### 防御性回落 (Fallback) 50 | 当服务器检测到非法的握手请求、超时的连接或格式错误的数据包时,不直接断开连接,而是将连接无缝转发至指定的诱饵地址(如 Nginx 或 Apache 服务器)。探测者只会看到一个普通的网页服务器响应。 51 | 52 | ### 缺点(TODO) 53 | 1. **数据包格式**: 原生 TCP,UDP 通过 UoT(UDP-over-TCP)隧道支持,暂不暴露原生 UDP 监听。 54 | 2. **带宽利用率**: 混淆会带来额外开销,可通过关闭 `enable_pure_downlink` 启用带宽优化下行来缓解下载场景。 55 | 3. **客户端代理**: 仅支持socks5/http。 56 | 4. **协议普及度**: 暂仅有官方和Mihomo支持, 57 | 58 | 59 | 60 | 61 | 62 | 63 | ## 快速开始 64 | 65 | ### 编译 66 | 67 | ```bash 68 | go build -o sudoku cmd/sudoku-tunnel/main.go 69 | ``` 70 | 71 | ### 服务端配置 (config.json) 72 | 73 | ```json 74 | { 75 | "mode": "server", 76 | "local_port": 1080, 77 | "server_address": "", 78 | "fallback_address": "127.0.0.1:80", 79 | "key": "见下面的运行步骤", 80 | "aead": "chacha20-poly1305", 81 | "suspicious_action": "fallback", 82 | "ascii": "prefer_entropy", 83 | "padding_min": 2, 84 | "padding_max": 7, 85 | "custom_table": "xpxvvpvv", 86 | "custom_tables": [ 87 | "xpxvvpvv", 88 | "vxpvxvvp" 89 | ], 90 | "enable_pure_downlink": true, 91 | "disable_http_mask": false, 92 | "http_mask_mode": "legacy", 93 | "http_mask_tls": false, 94 | "http_mask_host": "" 95 | } 96 | ``` 97 | 98 | 如需自定义字节特征,可以在配置中加入 `custom_table`(两个 `x`、两个 `p`、四个 `v`,如 `xpxvvpvv`,共 420 种排列);`"ascii": "prefer_ascii"` 会优先生效。 99 | 100 | 如需轮换多套布局(降低长期固定特征被统计学习的风险),使用 `custom_tables`(字符串列表)。当 `custom_tables` 非空时会覆盖 `custom_table`,并在每条连接中随机选择其一;服务端会在握手阶段自动探测表,无需额外明文协商字段。 101 | 102 | 注意:`sudoku://` 短链接已支持 `custom_tables`(字段 `ts`,并保留 `t` 作为单表回退)以及 CDN 相关的 HTTPMask 选项(`hm`/`ht`/`hh`);旧链接仍可正常解析。 103 | 104 | ### 客户端配置 105 | 106 | 将 `mode` 改为 `client`,并设置 `server_address` 为服务端 IP,将`local_port` 设置为代理监听端口,添加 `rule_urls` 使用`configs/config.json`的模板填充;如需带宽优化下行,将 `enable_pure_downlink` 置为 `false`。 107 | 108 | 如需走 CDN/代理(例如 Cloudflare 小黄云),设置: 109 | - `"disable_http_mask": false` 110 | - `"http_mask_mode": "auto"`(或 `"xhttp"` / `"pht"`) 111 | - 客户端 `server_address` 可填写域名(如 `"example.com:443"`);端口 `443` 会自动使用 HTTPS(或用 `"http_mask_tls": true` 强制)。 112 | 113 | **注意**:Key一定要用sudoku专门生成 114 | 115 | ### 运行 116 | 117 | > 务必先生成KeyPair 118 | ```bash 119 | $ ./sudoku -keygen 120 | Available Private Key: b1ec294d5dba60a800e1ef8c3423d5a176093f0d8c432e01bc24895d6828140aac81776fc0b44c3c08e418eb702b5e0a4c0a2dd458f8284d67f0d8d2d4bfdd0e 121 | Master Private Key: 709aab5f030c9b8c322811d5c6545497c2136ce1e43b574e231562303de8f108 122 | Master Public Key: 6e5c05c3f7f5d45fcd2f6a5a7f4700f94ff51db376c128c581849feb71ccc58b 123 | ``` 124 | 你需要将`Master Public Key`填入服务端配置的`key`,然后复制`Available Private Key`,填入客户端的`key`。 125 | 126 | 如果你需要生成更多与此公钥相对的私钥,请使用`-more`参数 + 已有的私钥/'Master Private Key': 127 | ```bash 128 | $ ./sudoku -keygen -more 709aab5f030c9b8c322811d5c6545497c2136ce1e43b574e231562303de8f108 129 | Split Private Key: 89acb9663cfd3bd04adf0001cc7000a8eb312903088b33a847d7e5cf102f1d0ad4c1e755e1717114bee50777d9dd3204d7e142dedcb023a6db3d7c602cb9d40e 130 | ``` 131 | 将此处的`Split Private Key`填入客户端配置的`key`。 132 | 133 | 指定 `config.json` 路径为参数运行程序 134 | ```bash 135 | ./sudoku -c config.json 136 | ``` 137 | 138 | ## 协议流程 139 | 140 | 1. **初始化**: 客户端与服务端根据预共享密钥(Key)生成相同的数独映射表。 141 | 2. **握手**: 客户端发送加密的时间戳与随机数。 142 | 3. **传输**: 数据 -> AEAD 加密 -> 切片 -> 映射为数独提示 -> 添加填充 -> 发送。 143 | 4. **接收**: 接收数据 -> 过滤填充 -> 还原数独提示 -> 查表解码 -> AEAD 解密。 144 | 145 | --- 146 | 147 | 148 | ## 声明 149 | > [!NOTE]\ 150 | > 此软件仅用于教育和研究目的。用户需自行遵守当地网络法规。 151 | 152 | ## 鸣谢 153 | 154 | - [链接1](https://gfw.report/publications/usenixsecurity23/zh/) 155 | - [链接2](https://github.com/enfein/mieru/issues/8) 156 | - [链接3](https://github.com/zhaohuabing/lightsocks) 157 | - [链接4](https://imciel.com/2020/08/27/create-custom-tunnel/) 158 | - [链接5](https://oeis.org/A109252) 159 | - [链接6](https://pi.math.cornell.edu/~mec/Summer2009/Mahmood/Four.html) 160 | 161 | 162 | ## Star History 163 | 164 | [![Star History Chart](https://api.star-history.com/svg?repos=saba-futai/sudoku&type=Date)](https://star-history.com/#saba-futai/sudoku) 165 | -------------------------------------------------------------------------------- /internal/tunnel/dialer.go: -------------------------------------------------------------------------------- 1 | package tunnel 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "crypto/sha256" 7 | "encoding/binary" 8 | "fmt" 9 | "net" 10 | "strings" 11 | "time" 12 | 13 | "github.com/saba-futai/sudoku/internal/config" 14 | "github.com/saba-futai/sudoku/internal/protocol" 15 | "github.com/saba-futai/sudoku/pkg/crypto" 16 | "github.com/saba-futai/sudoku/pkg/dnsutil" 17 | "github.com/saba-futai/sudoku/pkg/obfs/httpmask" 18 | "github.com/saba-futai/sudoku/pkg/obfs/sudoku" 19 | ) 20 | 21 | // Dialer abstracts the logic for establishing a connection to the server. 22 | type Dialer interface { 23 | Dial(destAddrStr string) (net.Conn, error) 24 | } 25 | 26 | // BaseDialer contains common logic for Sudoku connections. 27 | type BaseDialer struct { 28 | Config *config.Config 29 | Tables []*sudoku.Table 30 | PrivateKey []byte 31 | } 32 | 33 | func (d *BaseDialer) pickTable() (byte, *sudoku.Table, error) { 34 | if len(d.Tables) == 0 { 35 | return 0, nil, fmt.Errorf("no table configured") 36 | } 37 | if len(d.Tables) == 1 { 38 | return 0, d.Tables[0], nil 39 | } 40 | // Use crypto/rand to avoid shared global RNG in concurrent dialing. 41 | var b [1]byte 42 | if _, err := rand.Read(b[:]); err != nil { 43 | return 0, nil, fmt.Errorf("random table pick failed: %w", err) 44 | } 45 | idx := int(b[0]) % len(d.Tables) 46 | return byte(idx), d.Tables[idx], nil 47 | } 48 | 49 | func (d *BaseDialer) dialBase() (net.Conn, error) { 50 | // HTTP tunnel (CDN-friendly) modes. The returned conn already strips HTTP headers. 51 | if !d.Config.DisableHTTPMask { 52 | switch strings.ToLower(strings.TrimSpace(d.Config.HTTPMaskMode)) { 53 | case "xhttp", "pht", "auto": 54 | dialCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 55 | defer cancel() 56 | 57 | rawRemote, err := httpmask.DialTunnel(dialCtx, d.Config.ServerAddress, httpmask.TunnelDialOptions{ 58 | Mode: d.Config.HTTPMaskMode, 59 | TLSEnabled: d.Config.HTTPMaskTLS, 60 | HostOverride: d.Config.HTTPMaskHost, 61 | }) 62 | if err != nil { 63 | return nil, fmt.Errorf("dial http tunnel failed: %w", err) 64 | } 65 | 66 | tableID, table, err := d.pickTable() 67 | if err != nil { 68 | rawRemote.Close() 69 | return nil, err 70 | } 71 | return ClientHandshake(rawRemote, d.Config, table, tableID, d.PrivateKey) 72 | } 73 | } 74 | 75 | // Resolve server address with DNS concurrency and optimistic cache. 76 | resolveCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 77 | defer cancel() 78 | 79 | serverAddr, err := dnsutil.ResolveWithCache(resolveCtx, d.Config.ServerAddress) 80 | if err != nil { 81 | return nil, fmt.Errorf("resolve server address failed: %w", err) 82 | } 83 | 84 | // 1. Establish base TCP connection 85 | rawRemote, err := net.DialTimeout("tcp", serverAddr, 5*time.Second) 86 | if err != nil { 87 | return nil, fmt.Errorf("dial server failed: %w", err) 88 | } 89 | 90 | // 2. Send HTTP mask 91 | if !d.Config.DisableHTTPMask { 92 | // Legacy HTTP mask (not CDN-compatible): write a fake HTTP/1.1 header then switch to raw stream. 93 | if err := httpmask.WriteRandomRequestHeader(rawRemote, d.Config.ServerAddress); err != nil { 94 | rawRemote.Close() 95 | return nil, fmt.Errorf("write http mask failed: %w", err) 96 | } 97 | } 98 | 99 | tableID, table, err := d.pickTable() 100 | if err != nil { 101 | rawRemote.Close() 102 | return nil, err 103 | } 104 | return ClientHandshake(rawRemote, d.Config, table, tableID, d.PrivateKey) 105 | } 106 | 107 | // ClientHandshake upgrades a raw connection to a Sudoku connection 108 | func ClientHandshake(conn net.Conn, cfg *config.Config, table *sudoku.Table, tableID byte, privateKey []byte) (net.Conn, error) { 109 | if !cfg.EnablePureDownlink && cfg.AEAD == "none" { 110 | return nil, fmt.Errorf("enable_pure_downlink=false requires AEAD") 111 | } 112 | 113 | // 3. Sudoku encapsulation 114 | obfsConn := buildObfsConnForClient(conn, table, cfg) 115 | 116 | // 4. Encryption 117 | cConn, err := crypto.NewAEADConn(obfsConn, cfg.Key, cfg.AEAD) 118 | if err != nil { 119 | 120 | return nil, fmt.Errorf("crypto setup failed: %w", err) 121 | } 122 | 123 | // 5. Handshake 124 | handshake := make([]byte, 16) 125 | binary.BigEndian.PutUint64(handshake[:8], uint64(time.Now().Unix())) 126 | 127 | if len(privateKey) > 0 { 128 | // Use deterministic nonce from Private Key 129 | hash := sha256.Sum256(privateKey) 130 | copy(handshake[8:], hash[:8]) 131 | } else { 132 | // Fallback to random if no private key (legacy/server mode) 133 | if _, err := rand.Read(handshake[8:]); err != nil { 134 | return nil, fmt.Errorf("generate nonce failed: %w", err) 135 | } 136 | } 137 | handshake[8] = tableID 138 | 139 | if _, err := cConn.Write(handshake); err != nil { 140 | cConn.Close() 141 | return nil, fmt.Errorf("handshake failed: %w", err) 142 | } 143 | 144 | modeByte := []byte{downlinkModeByte(cfg)} 145 | if _, err := cConn.Write(modeByte); err != nil { 146 | cConn.Close() 147 | return nil, fmt.Errorf("write downlink mode failed: %w", err) 148 | } 149 | 150 | return cConn, nil 151 | } 152 | 153 | func (d *BaseDialer) dialUoT() (net.Conn, error) { 154 | conn, err := d.dialBase() 155 | if err != nil { 156 | return nil, err 157 | } 158 | if err := WriteUoTPreface(conn); err != nil { 159 | conn.Close() 160 | return nil, fmt.Errorf("uot preface failed: %w", err) 161 | } 162 | return conn, nil 163 | } 164 | 165 | // StandardDialer implements Dialer for standard Sudoku mode. 166 | type StandardDialer struct { 167 | BaseDialer 168 | } 169 | 170 | func (d *StandardDialer) Dial(destAddrStr string) (net.Conn, error) { 171 | cConn, err := d.dialBase() 172 | if err != nil { 173 | return nil, err 174 | } 175 | 176 | // Standard Mode: Write destination address directly 177 | if err := protocol.WriteAddress(cConn, destAddrStr); err != nil { 178 | cConn.Close() 179 | return nil, fmt.Errorf("write address failed: %w", err) 180 | } 181 | 182 | return cConn, nil 183 | } 184 | 185 | // DialUDPOverTCP establishes a UoT-capable tunnel for UDP proxying. 186 | func (d *StandardDialer) DialUDPOverTCP() (net.Conn, error) { 187 | return d.dialUoT() 188 | } 189 | -------------------------------------------------------------------------------- /tests/multi_table_rotation_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net" 7 | "sync" 8 | "testing" 9 | "time" 10 | 11 | "github.com/saba-futai/sudoku/apis" 12 | "github.com/saba-futai/sudoku/pkg/obfs/sudoku" 13 | ) 14 | 15 | func TestMultiTableRotation_ServerProbesTables(t *testing.T) { 16 | serverListener, err := net.Listen("tcp", "127.0.0.1:0") 17 | if err != nil { 18 | t.Fatalf("failed to listen: %v", err) 19 | } 20 | defer serverListener.Close() 21 | 22 | serverAddr := serverListener.Addr().String() 23 | key := "test-key-rotate" 24 | 25 | t1, err := sudoku.NewTableWithCustom("seed-1", "prefer_entropy", "xpxvvpvv") 26 | if err != nil { 27 | t.Fatalf("build t1: %v", err) 28 | } 29 | t2, err := sudoku.NewTableWithCustom("seed-2", "prefer_entropy", "vxpvxvvp") 30 | if err != nil { 31 | t.Fatalf("build t2: %v", err) 32 | } 33 | 34 | serverCfg := &apis.ProtocolConfig{ 35 | Key: key, 36 | AEADMethod: "chacha20-poly1305", 37 | Tables: []*sudoku.Table{t1, t2}, 38 | PaddingMin: 5, 39 | PaddingMax: 15, 40 | EnablePureDownlink: true, 41 | HandshakeTimeoutSeconds: 5, 42 | DisableHTTPMask: false, 43 | } 44 | 45 | var wg sync.WaitGroup 46 | wg.Add(1) 47 | go func() { 48 | defer wg.Done() 49 | for { 50 | conn, err := serverListener.Accept() 51 | if err != nil { 52 | return 53 | } 54 | go func(c net.Conn) { 55 | defer c.Close() 56 | tunnelConn, _, err := apis.ServerHandshake(c, serverCfg) 57 | if err != nil { 58 | return 59 | } 60 | defer tunnelConn.Close() 61 | io.Copy(tunnelConn, tunnelConn) 62 | }(conn) 63 | } 64 | }() 65 | 66 | clientBase := func(table *sudoku.Table, tables []*sudoku.Table) *apis.ProtocolConfig { 67 | return &apis.ProtocolConfig{ 68 | ServerAddress: serverAddr, 69 | TargetAddress: "example.com:80", 70 | Key: key, 71 | AEADMethod: "chacha20-poly1305", 72 | Table: table, 73 | Tables: tables, 74 | PaddingMin: 5, 75 | PaddingMax: 15, 76 | EnablePureDownlink: true, 77 | DisableHTTPMask: false, 78 | } 79 | } 80 | 81 | t.Run("ClientUsesTable1", func(t *testing.T) { 82 | conn, err := apis.Dial(context.Background(), clientBase(t1, nil)) 83 | if err != nil { 84 | t.Fatalf("dial failed: %v", err) 85 | } 86 | defer conn.Close() 87 | 88 | msg := []byte("hello t1") 89 | if _, err := conn.Write(msg); err != nil { 90 | t.Fatalf("write failed: %v", err) 91 | } 92 | buf := make([]byte, len(msg)) 93 | if _, err := io.ReadFull(conn, buf); err != nil { 94 | t.Fatalf("read failed: %v", err) 95 | } 96 | if string(buf) != string(msg) { 97 | t.Fatalf("expected %q, got %q", msg, buf) 98 | } 99 | }) 100 | 101 | t.Run("ClientUsesTable2", func(t *testing.T) { 102 | conn, err := apis.Dial(context.Background(), clientBase(t2, nil)) 103 | if err != nil { 104 | t.Fatalf("dial failed: %v", err) 105 | } 106 | defer conn.Close() 107 | 108 | msg := []byte("hello t2") 109 | if _, err := conn.Write(msg); err != nil { 110 | t.Fatalf("write failed: %v", err) 111 | } 112 | buf := make([]byte, len(msg)) 113 | if _, err := io.ReadFull(conn, buf); err != nil { 114 | t.Fatalf("read failed: %v", err) 115 | } 116 | if string(buf) != string(msg) { 117 | t.Fatalf("expected %q, got %q", msg, buf) 118 | } 119 | }) 120 | 121 | t.Run("ClientPicksFromTables", func(t *testing.T) { 122 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 123 | defer cancel() 124 | 125 | cfg := clientBase(nil, []*sudoku.Table{t1, t2}) 126 | for i := 0; i < 10; i++ { 127 | conn, err := apis.Dial(ctx, cfg) 128 | if err != nil { 129 | t.Fatalf("dial failed: %v", err) 130 | } 131 | msg := []byte("hello rotate") 132 | if _, err := conn.Write(msg); err != nil { 133 | conn.Close() 134 | t.Fatalf("write failed: %v", err) 135 | } 136 | buf := make([]byte, len(msg)) 137 | if _, err := io.ReadFull(conn, buf); err != nil { 138 | conn.Close() 139 | t.Fatalf("read failed: %v", err) 140 | } 141 | conn.Close() 142 | } 143 | }) 144 | } 145 | 146 | func TestMultiTableRotation_Stress(t *testing.T) { 147 | serverListener, err := net.Listen("tcp", "127.0.0.1:0") 148 | if err != nil { 149 | t.Fatalf("failed to listen: %v", err) 150 | } 151 | defer serverListener.Close() 152 | 153 | serverAddr := serverListener.Addr().String() 154 | key := "test-key-stress" 155 | 156 | t1, err := sudoku.NewTableWithCustom("seed-a", "prefer_entropy", "xpxvvpvv") 157 | if err != nil { 158 | t.Fatalf("build t1: %v", err) 159 | } 160 | t2, err := sudoku.NewTableWithCustom("seed-b", "prefer_entropy", "vxpvxvvp") 161 | if err != nil { 162 | t.Fatalf("build t2: %v", err) 163 | } 164 | 165 | serverCfg := &apis.ProtocolConfig{ 166 | Key: key, 167 | AEADMethod: "chacha20-poly1305", 168 | Tables: []*sudoku.Table{t1, t2}, 169 | PaddingMin: 0, 170 | PaddingMax: 0, 171 | EnablePureDownlink: true, 172 | HandshakeTimeoutSeconds: 5, 173 | DisableHTTPMask: false, 174 | } 175 | 176 | go func() { 177 | for { 178 | conn, err := serverListener.Accept() 179 | if err != nil { 180 | return 181 | } 182 | go func(c net.Conn) { 183 | defer c.Close() 184 | tunnelConn, _, err := apis.ServerHandshake(c, serverCfg) 185 | if err != nil { 186 | return 187 | } 188 | defer tunnelConn.Close() 189 | io.Copy(tunnelConn, tunnelConn) 190 | }(conn) 191 | } 192 | }() 193 | 194 | clientCfg := &apis.ProtocolConfig{ 195 | ServerAddress: serverAddr, 196 | TargetAddress: "example.com:80", 197 | Key: key, 198 | AEADMethod: "chacha20-poly1305", 199 | Tables: []*sudoku.Table{t1, t2}, 200 | PaddingMin: 0, 201 | PaddingMax: 0, 202 | EnablePureDownlink: true, 203 | DisableHTTPMask: false, 204 | } 205 | 206 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 207 | defer cancel() 208 | 209 | const clients = 50 210 | var wg sync.WaitGroup 211 | wg.Add(clients) 212 | for i := 0; i < clients; i++ { 213 | go func() { 214 | defer wg.Done() 215 | conn, err := apis.Dial(ctx, clientCfg) 216 | if err != nil { 217 | return 218 | } 219 | defer conn.Close() 220 | msg := []byte("ping") 221 | conn.Write(msg) 222 | buf := make([]byte, len(msg)) 223 | io.ReadFull(conn, buf) 224 | }() 225 | } 226 | wg.Wait() 227 | } 228 | -------------------------------------------------------------------------------- /internal/config/shortlink_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "net" 7 | "testing" 8 | ) 9 | 10 | func TestShortLinkRoundTrip_Client(t *testing.T) { 11 | cfg := &Config{ 12 | Mode: "client", 13 | LocalPort: 1081, 14 | ServerAddress: "8.8.8.8:443", 15 | Key: "deadbeef", 16 | AEAD: "aes-128-gcm", 17 | ASCII: "prefer_ascii", 18 | CustomTable: "xpxvvpvv", 19 | EnablePureDownlink: false, 20 | } 21 | 22 | link, err := BuildShortLinkFromConfig(cfg, "") 23 | if err != nil { 24 | t.Fatalf("BuildShortLinkFromConfig error: %v", err) 25 | } 26 | if link == "" { 27 | t.Fatalf("empty link") 28 | } 29 | 30 | decoded, err := BuildConfigFromShortLink(link) 31 | if err != nil { 32 | t.Fatalf("BuildConfigFromShortLink error: %v", err) 33 | } 34 | 35 | if decoded.ServerAddress != cfg.ServerAddress { 36 | t.Fatalf("server address mismatch, got %s", decoded.ServerAddress) 37 | } 38 | if decoded.LocalPort != cfg.LocalPort { 39 | t.Fatalf("local port mismatch, got %d", decoded.LocalPort) 40 | } 41 | if decoded.Key != cfg.Key { 42 | t.Fatalf("key mismatch, got %s", decoded.Key) 43 | } 44 | if decoded.AEAD != cfg.AEAD { 45 | t.Fatalf("aead mismatch, got %s", decoded.AEAD) 46 | } 47 | if decoded.CustomTable != cfg.CustomTable { 48 | t.Fatalf("custom table mismatch, got %s", decoded.CustomTable) 49 | } 50 | if decoded.EnablePureDownlink != cfg.EnablePureDownlink { 51 | t.Fatalf("downlink mode mismatch") 52 | } 53 | if decoded.ASCII != "prefer_ascii" { 54 | t.Fatalf("ascii mismatch, got %s", decoded.ASCII) 55 | } 56 | } 57 | 58 | func TestShortLinkRoundTrip_CustomTablesAndCDN(t *testing.T) { 59 | cfg := &Config{ 60 | Mode: "client", 61 | LocalPort: 1081, 62 | ServerAddress: "cc.futai.io:443", 63 | Key: "deadbeef", 64 | AEAD: "aes-128-gcm", 65 | ASCII: "prefer_entropy", 66 | CustomTables: []string{"xpxvvpvv", "vxpvxvvp"}, 67 | EnablePureDownlink: true, 68 | DisableHTTPMask: false, 69 | HTTPMaskMode: "auto", 70 | HTTPMaskTLS: true, 71 | } 72 | 73 | link, err := BuildShortLinkFromConfig(cfg, "") 74 | if err != nil { 75 | t.Fatalf("BuildShortLinkFromConfig error: %v", err) 76 | } 77 | 78 | decoded, err := BuildConfigFromShortLink(link) 79 | if err != nil { 80 | t.Fatalf("BuildConfigFromShortLink error: %v", err) 81 | } 82 | 83 | if decoded.ServerAddress != cfg.ServerAddress { 84 | t.Fatalf("server address mismatch, got %s", decoded.ServerAddress) 85 | } 86 | if len(decoded.CustomTables) != len(cfg.CustomTables) { 87 | t.Fatalf("custom tables length mismatch, got %d", len(decoded.CustomTables)) 88 | } 89 | for i := range cfg.CustomTables { 90 | if decoded.CustomTables[i] != cfg.CustomTables[i] { 91 | t.Fatalf("custom tables[%d] mismatch, got %s", i, decoded.CustomTables[i]) 92 | } 93 | } 94 | if decoded.CustomTable != cfg.CustomTables[0] { 95 | t.Fatalf("custom table fallback mismatch, got %s", decoded.CustomTable) 96 | } 97 | if decoded.HTTPMaskMode != "auto" { 98 | t.Fatalf("http mask mode mismatch, got %s", decoded.HTTPMaskMode) 99 | } 100 | if !decoded.HTTPMaskTLS { 101 | t.Fatalf("http mask tls mismatch, got %v", decoded.HTTPMaskTLS) 102 | } 103 | if decoded.DisableHTTPMask { 104 | t.Fatalf("disable http mask mismatch, got %v", decoded.DisableHTTPMask) 105 | } 106 | } 107 | 108 | func TestShortLinkIPv6ServerAddress(t *testing.T) { 109 | serverAddr := net.JoinHostPort("2001:db8::1", "443") 110 | cfg := &Config{ 111 | Mode: "client", 112 | LocalPort: 1081, 113 | ServerAddress: serverAddr, 114 | Key: "deadbeef", 115 | } 116 | 117 | link, err := BuildShortLinkFromConfig(cfg, "") 118 | if err != nil { 119 | t.Fatalf("BuildShortLinkFromConfig error: %v", err) 120 | } 121 | 122 | decoded, err := BuildConfigFromShortLink(link) 123 | if err != nil { 124 | t.Fatalf("BuildConfigFromShortLink error: %v", err) 125 | } 126 | if decoded.ServerAddress != serverAddr { 127 | t.Fatalf("server address mismatch, got %s", decoded.ServerAddress) 128 | } 129 | } 130 | 131 | func TestShortLinkAdvertiseServer(t *testing.T) { 132 | cfg := &Config{ 133 | Mode: "server", 134 | LocalPort: 9443, 135 | Key: "deadbeef", 136 | ASCII: "", 137 | AEAD: "", 138 | EnablePureDownlink: true, 139 | FallbackAddr: "127.0.0.1:80", 140 | } 141 | 142 | link, err := BuildShortLinkFromConfig(cfg, "example.com") 143 | if err != nil { 144 | t.Fatalf("BuildShortLinkFromConfig error: %v", err) 145 | } 146 | if link == "" { 147 | t.Fatalf("empty link") 148 | } 149 | } 150 | 151 | func TestShortLinkAdvertiseHostWithPort(t *testing.T) { 152 | cfg := &Config{ 153 | Mode: "server", 154 | LocalPort: 8080, 155 | Key: "deadbeef", 156 | EnablePureDownlink: true, 157 | DisableHTTPMask: false, 158 | HTTPMaskMode: "auto", 159 | } 160 | 161 | link, err := BuildShortLinkFromConfig(cfg, "cc.futai.io:443") 162 | if err != nil { 163 | t.Fatalf("BuildShortLinkFromConfig error: %v", err) 164 | } 165 | 166 | decoded, err := BuildConfigFromShortLink(link) 167 | if err != nil { 168 | t.Fatalf("BuildConfigFromShortLink error: %v", err) 169 | } 170 | if decoded.ServerAddress != "cc.futai.io:443" { 171 | t.Fatalf("server address mismatch, got %s", decoded.ServerAddress) 172 | } 173 | if decoded.HTTPMaskMode != "auto" { 174 | t.Fatalf("http mask mode mismatch, got %s", decoded.HTTPMaskMode) 175 | } 176 | } 177 | 178 | func TestShortLinkServerDeriveHostFromFallback(t *testing.T) { 179 | cfg := &Config{ 180 | Mode: "server", 181 | LocalPort: 10059, 182 | Key: "deadbeef", 183 | EnablePureDownlink: true, 184 | FallbackAddr: "8.219.204.112:11415", 185 | DisableHTTPMask: false, 186 | HTTPMaskMode: "pht", 187 | } 188 | 189 | link, err := BuildShortLinkFromConfig(cfg, "") 190 | if err != nil { 191 | t.Fatalf("BuildShortLinkFromConfig error: %v", err) 192 | } 193 | 194 | decoded, err := BuildConfigFromShortLink(link) 195 | if err != nil { 196 | t.Fatalf("BuildConfigFromShortLink error: %v", err) 197 | } 198 | 199 | if decoded.ServerAddress != "8.219.204.112:10059" { 200 | t.Fatalf("server address mismatch, got %s", decoded.ServerAddress) 201 | } 202 | if decoded.HTTPMaskMode != "pht" { 203 | t.Fatalf("http mask mode mismatch, got %s", decoded.HTTPMaskMode) 204 | } 205 | } 206 | 207 | func TestShortLinkInvalidScheme(t *testing.T) { 208 | if _, err := BuildConfigFromShortLink("http://bad"); err == nil { 209 | t.Fatalf("expected error for bad scheme") 210 | } 211 | } 212 | 213 | func TestShortLinkMissingFields(t *testing.T) { 214 | payload := map[string]string{} 215 | raw, _ := json.Marshal(payload) 216 | link := "sudoku://" + base64.RawURLEncoding.EncodeToString(raw) 217 | if _, err := BuildConfigFromShortLink(link); err == nil { 218 | t.Fatalf("expected error for missing fields") 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /apis/config.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) 2025 by ふたい 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | 17 | In addition, no derivative work may use the name or imply association 18 | with this application without prior consent. 19 | */ 20 | package apis 21 | 22 | import ( 23 | "fmt" 24 | "strings" 25 | 26 | "github.com/saba-futai/sudoku/pkg/obfs/sudoku" 27 | ) 28 | 29 | // ProtocolConfig 定义了 Sudoku 协议栈所需的所有参数 30 | // 31 | // Sudoku 协议是一个多层的加密隧道协议: 32 | // 1. HTTP 伪装层:伪装成 HTTP POST 请求 33 | // 2. Sudoku 混淆层:使用数独谜题编码混淆流量特征 34 | // 3. AEAD 加密层:提供机密性和完整性保护 35 | // 4. 协议层:处理握手、地址传输等 36 | type ProtocolConfig struct { 37 | // ============ 基础连接信息 ============ 38 | 39 | // ServerAddress 服务器地址 (仅客户端使用) 40 | // 格式: "host:port" 或 "ip:port" 41 | // 例如: "example.com:443" 或 "1.2.3.4:8080" 42 | ServerAddress string 43 | 44 | // ============ 加密与混淆 ============ 45 | 46 | // Key 预共享密钥,用于 AEAD 加密 47 | // 字符串两端一致即可;可直接使用 "./sudoku -keygen" 生成的密钥字符串或自行约定共享密钥 48 | Key string 49 | 50 | // AEADMethod 指定使用的 AEAD 加密算法 51 | // 有效值: 52 | // - "aes-128-gcm": AES-128-GCM (较快,硬件加速支持好) 53 | // - "chacha20-poly1305": ChaCha20-Poly1305 (纯软件实现性能好) 54 | // - "none": 不加密 (仅用于测试,生产环境禁用) 55 | AEADMethod string 56 | 57 | // Table Sudoku 编码映射表 (客户端和服务端必须相同) 58 | // 使用 sudoku.NewTable(seed, "prefer_ascii"|"prefer_entropy") 或 59 | // sudoku.NewTableWithCustom(seed, "prefer_entropy", "") 创建 60 | // 不能为 nil 61 | Table *sudoku.Table 62 | 63 | // Tables is an optional candidate set for table rotation. 64 | // If provided (len>0), the client will pick one table per connection and the server will 65 | // probe the handshake to detect which one was used, keeping the handshake format unchanged. 66 | // When Tables is set, Table may be nil. 67 | Tables []*sudoku.Table 68 | 69 | // ============ Sudoku 填充参数 ============ 70 | 71 | // PaddingMin 最小填充率 (0-100) 72 | // 在编码时随机插入填充字节的最小概率百分比 73 | PaddingMin int 74 | 75 | // PaddingMax 最大填充率 (0-100) 76 | // 在编码时随机插入填充字节的最大概率百分比 77 | // 必须 >= PaddingMin 78 | PaddingMax int 79 | 80 | // EnablePureDownlink 是否保持纯 Sudoku 下行 81 | // false 时启用带宽优化的 6bit 拆分下行,要求 AEAD 启用 82 | EnablePureDownlink bool 83 | 84 | // ============ 客户端特有字段 ============ 85 | 86 | // TargetAddress 客户端想要访问的最终目标地址 (仅客户端使用) 87 | // 格式: "host:port" 88 | // 例如: "google.com:443" 或 "1.1.1.1:53" 89 | TargetAddress string 90 | 91 | // ============ 服务端特有字段 ============ 92 | 93 | // HandshakeTimeoutSeconds 握手超时时间(秒)(仅服务端使用) 94 | // 推荐值: 5-10 95 | // 设置过小可能导致慢速网络握手失败 96 | // 设置过大可能使服务器容易受到慢速攻击 97 | HandshakeTimeoutSeconds int 98 | 99 | // ============ 通用开关 ============ 100 | 101 | // DisableHTTPMask 是否禁用 HTTP 伪装层 102 | // 默认 false (启用伪装) 103 | // 如果为 true,客户端不发送伪装头,服务端也不检测伪装头 104 | // 注意:服务端支持自动检测,即使此项为 false,也能处理不带伪装头的客户端(前提是首字节不匹配 POST) 105 | DisableHTTPMask bool 106 | 107 | // HTTPMaskMode controls how the "HTTP mask" behaves: 108 | // - "legacy": write a fake HTTP/1.1 header then switch to raw stream (default, not CDN-compatible) 109 | // - "xhttp": real HTTP tunnel (stream-one), CDN-compatible 110 | // - "pht": plain HTTP tunnel (authorize/push/pull), strong restricted-network pass-through 111 | // - "auto": try xhttp then fall back to pht 112 | HTTPMaskMode string 113 | 114 | // HTTPMaskTLSEnabled enables HTTPS for HTTP tunnel modes (client-side). If false, the default is auto-inferred 115 | // from ServerAddress port (443 => HTTPS, otherwise HTTP). 116 | HTTPMaskTLSEnabled bool 117 | 118 | // HTTPMaskHost optionally overrides the HTTP Host header / SNI host for HTTP tunnel modes (client-side). 119 | // When empty, it is derived from ServerAddress. 120 | HTTPMaskHost string 121 | } 122 | 123 | // Validate 验证配置的有效性 124 | // 返回第一个发现的错误,如果配置有效则返回 nil 125 | func (c *ProtocolConfig) Validate() error { 126 | if c.Table == nil && len(c.Tables) == 0 { 127 | return fmt.Errorf("Table cannot be nil (or provide Tables)") 128 | } 129 | for i, t := range c.Tables { 130 | if t == nil { 131 | return fmt.Errorf("Tables[%d] cannot be nil", i) 132 | } 133 | } 134 | 135 | if c.Key == "" { 136 | return fmt.Errorf("Key cannot be empty") 137 | } 138 | 139 | switch c.AEADMethod { 140 | case "aes-128-gcm", "chacha20-poly1305", "none": 141 | // 有效值 142 | default: 143 | return fmt.Errorf("invalid AEADMethod: %s, must be one of: aes-128-gcm, chacha20-poly1305, none", c.AEADMethod) 144 | } 145 | 146 | if c.PaddingMin < 0 || c.PaddingMin > 100 { 147 | return fmt.Errorf("PaddingMin must be between 0 and 100, got %d", c.PaddingMin) 148 | } 149 | 150 | if c.PaddingMax < 0 || c.PaddingMax > 100 { 151 | return fmt.Errorf("PaddingMax must be between 0 and 100, got %d", c.PaddingMax) 152 | } 153 | 154 | if c.PaddingMax < c.PaddingMin { 155 | return fmt.Errorf("PaddingMax (%d) must be >= PaddingMin (%d)", c.PaddingMax, c.PaddingMin) 156 | } 157 | 158 | if !c.EnablePureDownlink && c.AEADMethod == "none" { 159 | return fmt.Errorf("bandwidth optimized downlink requires AEAD") 160 | } 161 | 162 | if c.HandshakeTimeoutSeconds < 0 { 163 | return fmt.Errorf("HandshakeTimeoutSeconds must be >= 0, got %d", c.HandshakeTimeoutSeconds) 164 | } 165 | 166 | switch strings.ToLower(strings.TrimSpace(c.HTTPMaskMode)) { 167 | case "", "legacy", "xhttp", "pht", "auto": 168 | default: 169 | return fmt.Errorf("invalid HTTPMaskMode: %s, must be one of: legacy, xhttp, pht, auto", c.HTTPMaskMode) 170 | } 171 | 172 | return nil 173 | } 174 | 175 | // ValidateClient ensures the config carries the required client-side fields. 176 | func (c *ProtocolConfig) ValidateClient() error { 177 | if err := c.Validate(); err != nil { 178 | return err 179 | } 180 | if c.ServerAddress == "" { 181 | return fmt.Errorf("ServerAddress cannot be empty") 182 | } 183 | if c.TargetAddress == "" { 184 | return fmt.Errorf("TargetAddress cannot be empty") 185 | } 186 | return nil 187 | } 188 | 189 | // DefaultConfig 返回一个安全的默认配置 190 | // 注意:返回的配置仍需设置 Key、Table、ServerAddress (客户端) 或 TargetAddress (服务端) 191 | func DefaultConfig() *ProtocolConfig { 192 | return &ProtocolConfig{ 193 | AEADMethod: "chacha20-poly1305", 194 | PaddingMin: 10, 195 | PaddingMax: 30, 196 | EnablePureDownlink: true, 197 | HandshakeTimeoutSeconds: 5, 198 | HTTPMaskMode: "legacy", 199 | } 200 | } 201 | 202 | func (c *ProtocolConfig) tableCandidates() []*sudoku.Table { 203 | if c == nil { 204 | return nil 205 | } 206 | if len(c.Tables) > 0 { 207 | return c.Tables 208 | } 209 | if c.Table != nil { 210 | return []*sudoku.Table{c.Table} 211 | } 212 | return nil 213 | } 214 | -------------------------------------------------------------------------------- /pkg/obfs/httpmask/tunnel_test.go: -------------------------------------------------------------------------------- 1 | package httpmask 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net" 10 | "net/http" 11 | "strings" 12 | "testing" 13 | "time" 14 | ) 15 | 16 | func TestCanonicalHeaderHost(t *testing.T) { 17 | tests := []struct { 18 | name string 19 | urlHost string 20 | scheme string 21 | wantHost string 22 | }{ 23 | {name: "https default port strips", urlHost: "example.com:443", scheme: "https", wantHost: "example.com"}, 24 | {name: "http default port strips", urlHost: "example.com:80", scheme: "http", wantHost: "example.com"}, 25 | {name: "non-default port keeps", urlHost: "example.com:8443", scheme: "https", wantHost: "example.com:8443"}, 26 | {name: "unknown scheme keeps", urlHost: "example.com:443", scheme: "ftp", wantHost: "example.com:443"}, 27 | {name: "ipv6 https strips brackets kept", urlHost: "[::1]:443", scheme: "https", wantHost: "[::1]"}, 28 | {name: "ipv6 non-default keeps", urlHost: "[::1]:8080", scheme: "http", wantHost: "[::1]:8080"}, 29 | {name: "no port returns input", urlHost: "example.com", scheme: "https", wantHost: "example.com"}, 30 | } 31 | 32 | for _, tt := range tests { 33 | t.Run(tt.name, func(t *testing.T) { 34 | if got := canonicalHeaderHost(tt.urlHost, tt.scheme); got != tt.wantHost { 35 | t.Fatalf("canonicalHeaderHost(%q, %q) = %q, want %q", tt.urlHost, tt.scheme, got, tt.wantHost) 36 | } 37 | }) 38 | } 39 | } 40 | 41 | func TestDialTunnel_Auto_FallsBackToPHTWithFreshContext(t *testing.T) { 42 | prevX := dialXHTTPFn 43 | prevP := dialPHTFn 44 | t.Cleanup(func() { 45 | dialXHTTPFn = prevX 46 | dialPHTFn = prevP 47 | }) 48 | 49 | var xCalled, pCalled int 50 | dialXHTTPFn = func(ctx context.Context, serverAddress string, opts TunnelDialOptions) (net.Conn, error) { 51 | xCalled++ 52 | dl, ok := ctx.Deadline() 53 | if !ok { 54 | t.Fatalf("xhttp ctx missing deadline") 55 | } 56 | remain := time.Until(dl) 57 | if remain < 2*time.Second || remain > 4*time.Second { 58 | t.Fatalf("xhttp ctx deadline not in expected range, remaining=%s", remain) 59 | } 60 | return nil, errors.New("xhttp forced fail") 61 | } 62 | 63 | var peer net.Conn 64 | dialPHTFn = func(ctx context.Context, serverAddress string, opts TunnelDialOptions) (net.Conn, error) { 65 | pCalled++ 66 | if ctx.Err() != nil { 67 | return nil, ctx.Err() 68 | } 69 | c1, c2 := net.Pipe() 70 | peer = c2 71 | return c1, nil 72 | } 73 | 74 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 75 | defer cancel() 76 | 77 | c, err := DialTunnel(ctx, "example.com:443", TunnelDialOptions{Mode: "auto", TLSEnabled: true}) 78 | if err != nil { 79 | t.Fatalf("DialTunnel(auto) error: %v", err) 80 | } 81 | if xCalled != 1 || pCalled != 1 { 82 | _ = c.Close() 83 | if peer != nil { 84 | _ = peer.Close() 85 | } 86 | t.Fatalf("unexpected calls: xhttp=%d pht=%d", xCalled, pCalled) 87 | } 88 | _ = c.Close() 89 | if peer != nil { 90 | _ = peer.Close() 91 | } 92 | } 93 | 94 | func TestTunnelServer_XHTTP_SplitSession_PushPull(t *testing.T) { 95 | srv := NewTunnelServer(TunnelServerOptions{ 96 | Mode: "xhttp", 97 | PHTPullReadTimeout: 50 * time.Millisecond, 98 | PHTSessionTTL: 5 * time.Second, 99 | }) 100 | 101 | authorize := func() (token string, stream net.Conn) { 102 | client, server := net.Pipe() 103 | t.Cleanup(func() { _ = client.Close() }) 104 | 105 | var ( 106 | res HandleResult 107 | c net.Conn 108 | err error 109 | ) 110 | done := make(chan struct{}) 111 | go func() { 112 | res, c, err = srv.HandleConn(server) 113 | close(done) 114 | }() 115 | 116 | _, _ = io.WriteString(client, 117 | "GET /session HTTP/1.1\r\n"+ 118 | "Host: example.com\r\n"+ 119 | "X-Sudoku-Tunnel: xhttp\r\n"+ 120 | "X-Sudoku-Version: 1\r\n"+ 121 | "\r\n") 122 | raw, _ := io.ReadAll(client) 123 | <-done 124 | 125 | if err != nil { 126 | t.Fatalf("authorize HandleConn error: %v", err) 127 | } 128 | if res != HandleStartTunnel || c == nil { 129 | t.Fatalf("authorize unexpected result: res=%v conn=%v", res, c) 130 | } 131 | 132 | parts := strings.SplitN(string(raw), "\r\n\r\n", 2) 133 | if len(parts) != 2 { 134 | _ = c.Close() 135 | t.Fatalf("authorize invalid http response: %q", string(raw)) 136 | } 137 | body := strings.TrimSpace(parts[1]) 138 | if !strings.HasPrefix(body, "token=") { 139 | _ = c.Close() 140 | t.Fatalf("authorize missing token, body=%q", body) 141 | } 142 | token = strings.TrimPrefix(body, "token=") 143 | if token == "" { 144 | _ = c.Close() 145 | t.Fatalf("authorize empty token") 146 | } 147 | return token, c 148 | } 149 | 150 | token, stream := authorize() 151 | t.Cleanup(func() { 152 | srv.phtClose(token) 153 | _ = stream.Close() 154 | }) 155 | 156 | // Push bytes into the session. 157 | { 158 | client, server := net.Pipe() 159 | done := make(chan struct{}) 160 | go func() { 161 | _, _, _ = srv.HandleConn(server) 162 | close(done) 163 | }() 164 | 165 | payload := "abc" 166 | type readResult struct { 167 | b []byte 168 | err error 169 | } 170 | readCh := make(chan readResult, 1) 171 | go func() { 172 | buf := make([]byte, len(payload)) 173 | _ = stream.SetReadDeadline(time.Now().Add(2 * time.Second)) 174 | _, err := io.ReadFull(stream, buf) 175 | readCh <- readResult{b: buf, err: err} 176 | }() 177 | 178 | _, _ = io.WriteString(client, fmt.Sprintf( 179 | "POST /api/v1/upload?token=%s HTTP/1.1\r\n"+ 180 | "Host: example.com\r\n"+ 181 | "X-Sudoku-Tunnel: xhttp\r\n"+ 182 | "X-Sudoku-Version: 1\r\n"+ 183 | "Content-Length: %d\r\n"+ 184 | "\r\n"+ 185 | "%s", token, len(payload), payload)) 186 | _, _ = io.ReadAll(client) 187 | <-done 188 | _ = client.Close() 189 | 190 | rr := <-readCh 191 | if rr.err != nil { 192 | t.Fatalf("read pushed payload error: %v", rr.err) 193 | } 194 | if got := string(rr.b); got != payload { 195 | t.Fatalf("pushed payload mismatch: got %q want %q", got, payload) 196 | } 197 | } 198 | 199 | // Pull bytes from the session. 200 | { 201 | client, server := net.Pipe() 202 | done := make(chan struct{}) 203 | go func() { 204 | _, _, _ = srv.HandleConn(server) 205 | close(done) 206 | }() 207 | 208 | _, _ = io.WriteString(client, fmt.Sprintf( 209 | "GET /stream?token=%s HTTP/1.1\r\n"+ 210 | "Host: example.com\r\n"+ 211 | "X-Sudoku-Tunnel: xhttp\r\n"+ 212 | "X-Sudoku-Version: 1\r\n"+ 213 | "\r\n", token)) 214 | 215 | br := bufio.NewReader(client) 216 | resp, err := http.ReadResponse(br, &http.Request{Method: http.MethodGet}) 217 | if err != nil { 218 | t.Fatalf("read pull response error: %v", err) 219 | } 220 | defer resp.Body.Close() 221 | if resp.StatusCode != http.StatusOK { 222 | t.Fatalf("pull status=%s", resp.Status) 223 | } 224 | 225 | writeDone := make(chan struct{}) 226 | go func() { 227 | _, _ = stream.Write([]byte("xyz")) 228 | close(writeDone) 229 | }() 230 | 231 | body, _ := io.ReadAll(resp.Body) 232 | <-writeDone 233 | <-done 234 | _ = client.Close() 235 | 236 | if string(body) != "xyz" { 237 | t.Fatalf("pulled payload mismatch: got %q want %q", string(body), "xyz") 238 | } 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /internal/config/shortlink.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | // shortLinkPayload holds the minimal fields we expose in sudoku:// links. 14 | type shortLinkPayload struct { 15 | Host string `json:"h"` // server host / IP 16 | Port int `json:"p"` // server port 17 | Key string `json:"k"` // shared key 18 | ASCII string `json:"a,omitempty"` // "ascii" or "entropy" 19 | AEAD string `json:"e,omitempty"` // AEAD method 20 | MixPort int `json:"m,omitempty"` // local mixed proxy port 21 | PackedDownlink bool `json:"x,omitempty"` // bandwidth-optimized downlink (non-pure Sudoku) 22 | CustomTable string `json:"t,omitempty"` // optional custom byte layout 23 | CustomTables []string `json:"ts,omitempty"` // optional custom byte layouts (rotation) 24 | // HTTP mask / tunnel controls (optional). 25 | DisableHTTPMask bool `json:"hd,omitempty"` // when true, disable HTTP mask completely 26 | HTTPMaskMode string `json:"hm,omitempty"` // "legacy" / "xhttp" / "pht" / "auto" 27 | HTTPMaskTLS bool `json:"ht,omitempty"` // enable HTTPS explicitly (otherwise inferred by port) 28 | HTTPMaskHost string `json:"hh,omitempty"` // override HTTP Host/SNI in tunnel modes 29 | } 30 | 31 | // BuildShortLinkFromConfig builds a sudoku:// short link from the provided config. 32 | // 33 | // If cfg.ServerAddress is empty, advertiseHost can be used to provide the public host[:port]. 34 | // For server configs, when advertiseHost is empty, we try to derive the host from fallback_address (host part) 35 | // and use local_port as the advertised port. 36 | func BuildShortLinkFromConfig(cfg *Config, advertiseHost string) (string, error) { 37 | if cfg == nil { 38 | return "", errors.New("nil config") 39 | } 40 | 41 | host, port, err := deriveAdvertiseAddress(cfg, advertiseHost) 42 | if err != nil { 43 | return "", err 44 | } 45 | 46 | payload := shortLinkPayload{ 47 | Host: host, 48 | Port: port, 49 | Key: cfg.Key, 50 | AEAD: cfg.AEAD, 51 | } 52 | 53 | if cfg.Mode == "client" && cfg.LocalPort > 0 { 54 | payload.MixPort = cfg.LocalPort 55 | } 56 | if payload.MixPort == 0 { 57 | payload.MixPort = 1080 // reasonable default for mixed proxy 58 | } 59 | 60 | payload.PackedDownlink = !cfg.EnablePureDownlink 61 | payload.CustomTable = cfg.CustomTable 62 | if len(cfg.CustomTables) > 0 { 63 | payload.CustomTables = append([]string(nil), cfg.CustomTables...) 64 | // Keep field "t" as a backward-compatible single-table fallback for older clients. 65 | if strings.TrimSpace(payload.CustomTable) == "" { 66 | payload.CustomTable = cfg.CustomTables[0] 67 | } 68 | } 69 | 70 | payload.DisableHTTPMask = cfg.DisableHTTPMask 71 | mode := strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) 72 | if mode != "" && mode != "legacy" { 73 | payload.HTTPMaskMode = mode 74 | } 75 | if cfg.HTTPMaskTLS { 76 | payload.HTTPMaskTLS = true 77 | } 78 | if strings.TrimSpace(cfg.HTTPMaskHost) != "" { 79 | payload.HTTPMaskHost = strings.TrimSpace(cfg.HTTPMaskHost) 80 | } 81 | 82 | payload.ASCII = encodeASCII(cfg.ASCII) 83 | if payload.AEAD == "" { 84 | payload.AEAD = "chacha20-poly1305" 85 | } 86 | 87 | data, err := json.Marshal(payload) 88 | if err != nil { 89 | return "", err 90 | } 91 | 92 | return "sudoku://" + base64.RawURLEncoding.EncodeToString(data), nil 93 | } 94 | 95 | // BuildConfigFromShortLink parses a sudoku:// short link and returns a client config. 96 | // The generated config is ready to run a PAC proxy. 97 | func BuildConfigFromShortLink(link string) (*Config, error) { 98 | if !strings.HasPrefix(link, "sudoku://") { 99 | return nil, errors.New("invalid scheme") 100 | } 101 | 102 | encoded := strings.TrimPrefix(link, "sudoku://") 103 | raw, err := base64.RawURLEncoding.DecodeString(encoded) 104 | if err != nil { 105 | return nil, fmt.Errorf("decode short link failed: %w", err) 106 | } 107 | 108 | var payload shortLinkPayload 109 | if err := json.Unmarshal(raw, &payload); err != nil { 110 | return nil, fmt.Errorf("invalid short link payload: %w", err) 111 | } 112 | 113 | if payload.Host == "" || payload.Port == 0 || payload.Key == "" { 114 | return nil, errors.New("short link missing required fields") 115 | } 116 | 117 | cfg := &Config{ 118 | Mode: "client", 119 | Transport: "tcp", 120 | LocalPort: payload.MixPort, 121 | ServerAddress: net.JoinHostPort(payload.Host, strconv.Itoa(payload.Port)), 122 | Key: payload.Key, 123 | CustomTable: payload.CustomTable, 124 | CustomTables: append([]string(nil), payload.CustomTables...), 125 | DisableHTTPMask: payload.DisableHTTPMask, 126 | HTTPMaskMode: payload.HTTPMaskMode, 127 | HTTPMaskTLS: payload.HTTPMaskTLS, 128 | HTTPMaskHost: payload.HTTPMaskHost, 129 | AEAD: payload.AEAD, 130 | PaddingMin: 5, 131 | PaddingMax: 15, 132 | ProxyMode: "pac", 133 | RuleURLs: []string{ 134 | "https://gh-proxy.org/https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Clash/China/China.list", 135 | "https://gh-proxy.org/https://raw.githubusercontent.com/fernvenue/chn-cidr-list/master/ipv4.yaml", 136 | }, 137 | } 138 | 139 | if cfg.LocalPort == 0 { 140 | cfg.LocalPort = 1080 141 | } 142 | 143 | cfg.EnablePureDownlink = !payload.PackedDownlink 144 | if strings.TrimSpace(cfg.HTTPMaskMode) == "" { 145 | cfg.HTTPMaskMode = "legacy" 146 | } 147 | 148 | cfg.ASCII = decodeASCII(payload.ASCII) 149 | if cfg.AEAD == "" { 150 | cfg.AEAD = "none" 151 | } 152 | 153 | return cfg, nil 154 | } 155 | 156 | func encodeASCII(mode string) string { 157 | if strings.ToLower(mode) == "prefer_ascii" || mode == "ascii" { 158 | return "ascii" 159 | } 160 | return "entropy" 161 | } 162 | 163 | func decodeASCII(val string) string { 164 | switch strings.ToLower(val) { 165 | case "ascii", "prefer_ascii": 166 | return "prefer_ascii" 167 | default: 168 | return "prefer_entropy" 169 | } 170 | } 171 | 172 | func deriveAdvertiseAddress(cfg *Config, advertiseHost string) (string, int, error) { 173 | if cfg.ServerAddress != "" { 174 | host, portStr, err := net.SplitHostPort(cfg.ServerAddress) 175 | if err != nil { 176 | return "", 0, fmt.Errorf("invalid server_address: %w", err) 177 | } 178 | port, err := strconv.Atoi(portStr) 179 | if err != nil { 180 | return "", 0, fmt.Errorf("invalid port in server_address: %w", err) 181 | } 182 | return host, port, nil 183 | } 184 | 185 | if advertiseHost != "" { 186 | // Allow advertiseHost in either "host" form (use cfg.LocalPort) or "host:port" form (explicit port). 187 | if h, p, err := net.SplitHostPort(advertiseHost); err == nil && h != "" && p != "" { 188 | port, err := strconv.Atoi(p) 189 | if err != nil { 190 | return "", 0, fmt.Errorf("invalid port in advertise host: %w", err) 191 | } 192 | return h, port, nil 193 | } 194 | if cfg.LocalPort > 0 { 195 | return advertiseHost, cfg.LocalPort, nil 196 | } 197 | } 198 | 199 | // Best-effort fallback for server-side configs: 200 | // if the user didn't provide a public host (CLI) nor server_address (config), 201 | // try to reuse fallback_address's host as the advertised host. 202 | // 203 | // This makes `-export-link` usable with typical server configs where the fallback 204 | // runs on the same machine (e.g. 127.0.0.1:80) or same public IP but different port. 205 | if cfg.Mode == "server" && advertiseHost == "" && cfg.LocalPort > 0 && cfg.FallbackAddr != "" { 206 | if h, _, err := net.SplitHostPort(cfg.FallbackAddr); err == nil && h != "" { 207 | return h, cfg.LocalPort, nil 208 | } 209 | } 210 | 211 | return "", 0, errors.New("cannot derive server address; set server_address or provide advertise host") 212 | } 213 | -------------------------------------------------------------------------------- /pkg/geodata/manager.go: -------------------------------------------------------------------------------- 1 | // pkg/geodata/manager.go 2 | package geodata 3 | 4 | import ( 5 | "bytes" 6 | "encoding/binary" 7 | "io" 8 | "log" 9 | "net" 10 | "net/http" 11 | "sort" 12 | "strings" 13 | "sync" 14 | "time" 15 | 16 | "gopkg.in/yaml.v3" 17 | ) 18 | 19 | const ( 20 | // LAN IP ranges in uint32 format 21 | lanRange1Start = 167772160 // 10.0.0.0 22 | lanRange1End = 184549375 // 10.255.255.255 23 | lanRange2Start = 2886729728 // 172.16.0.0 24 | lanRange2End = 2887778303 // 172.31.255.255 25 | lanRange3Start = 3232235520 // 192.168.0.0 26 | lanRange3End = 3232301055 // 192.168.255.255 27 | lanRange4Start = 2130706432 // 127.0.0.0 28 | lanRange4End = 2147483647 // 127.255.255.255 29 | ) 30 | 31 | // IPRange 表示一个 IP 区间 [Start, End] 32 | type IPRange struct { 33 | Start uint32 34 | End uint32 35 | } 36 | 37 | type Manager struct { 38 | ipRanges []IPRange 39 | domainExact map[string]struct{} // 精确匹配 DOMAIN 40 | domainSuffix map[string]struct{} // 后缀匹配 DOMAIN-SUFFIX 41 | mu sync.RWMutex 42 | urls []string 43 | } 44 | 45 | // RuleSet 用于解析 YAML 格式的 payload 46 | type RuleSet struct { 47 | Payload []string `yaml:"payload"` 48 | } 49 | 50 | var instance *Manager 51 | var once sync.Once 52 | 53 | // GetInstance 单例模式 54 | func GetInstance(urls []string) *Manager { 55 | once.Do(func() { 56 | instance = &Manager{ 57 | urls: urls, 58 | domainExact: make(map[string]struct{}), 59 | domainSuffix: make(map[string]struct{}), 60 | } 61 | go instance.Update() 62 | }) 63 | return instance 64 | } 65 | 66 | func (m *Manager) Update() { 67 | log.Printf("[GeoData] Updating rules from %d sources...", len(m.urls)) 68 | 69 | var tempRanges []IPRange 70 | tempExact := make(map[string]struct{}) 71 | tempSuffix := make(map[string]struct{}) 72 | 73 | for _, u := range m.urls { 74 | m.downloadAndParse(u, &tempRanges, tempExact, tempSuffix) 75 | } 76 | 77 | // 优化 IP 区间 78 | mergedIPs := mergeRanges(tempRanges) 79 | 80 | m.mu.Lock() 81 | m.ipRanges = mergedIPs 82 | m.domainExact = tempExact 83 | m.domainSuffix = tempSuffix 84 | m.mu.Unlock() 85 | 86 | log.Printf("[GeoData] Rules Updated: %d IP Ranges, %d Domains, %d Suffixes", 87 | len(mergedIPs), len(tempExact), len(tempSuffix)) 88 | } 89 | 90 | func (m *Manager) downloadAndParse(url string, ipRanges *[]IPRange, exact, suffix map[string]struct{}) { 91 | client := http.Client{Timeout: 30 * time.Second} 92 | resp, err := client.Get(url) 93 | if err != nil { 94 | log.Printf("[GeoData] Failed to download %s: %v", url, err) 95 | return 96 | } 97 | defer resp.Body.Close() 98 | 99 | // 读取全部内容 100 | body, err := io.ReadAll(resp.Body) 101 | if err != nil { 102 | log.Printf("[GeoData] Failed to read body from %s: %v", url, err) 103 | return 104 | } 105 | 106 | // 1. 尝试作为 YAML 解析 107 | var rs RuleSet 108 | if err := yaml.Unmarshal(body, &rs); err == nil && len(rs.Payload) > 0 { 109 | for _, rule := range rs.Payload { 110 | m.parseRule(rule, ipRanges, exact, suffix) 111 | } 112 | return 113 | } 114 | 115 | // 2. 兼容模式:如果 YAML 解析失败(例如是纯文本列表),则按行解析 116 | // 这能兼容一些纯文本的 .list 文件,同时通过上面的逻辑支持统一的 YAML payload 117 | scanner := bytes.NewBuffer(body) 118 | for { 119 | line, err := scanner.ReadString('\n') 120 | if err != nil && err != io.EOF { 121 | break 122 | } 123 | m.parseRule(line, ipRanges, exact, suffix) 124 | if err == io.EOF { 125 | break 126 | } 127 | } 128 | } 129 | 130 | // parseRule 统一处理单行规则字符串 131 | func (m *Manager) parseRule(line string, ipRanges *[]IPRange, exact, suffix map[string]struct{}) { 132 | line = strings.TrimSpace(line) 133 | if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") { 134 | return 135 | } 136 | 137 | // 1. 尝试解析 Clash 格式: TYPE,VALUE,... 138 | // 格式如: DOMAIN,baidu.com 或 IP-CIDR,1.2.3.4/24,no-resolve 139 | parts := strings.Split(line, ",") 140 | if len(parts) >= 2 { 141 | ruleType := strings.TrimSpace(strings.ToUpper(parts[0])) 142 | ruleValue := strings.TrimSpace(parts[1]) 143 | 144 | switch ruleType { 145 | case "DOMAIN": 146 | exact[ruleValue] = struct{}{} 147 | case "DOMAIN-SUFFIX": 148 | suffix[ruleValue] = struct{}{} 149 | case "IP-CIDR", "IP-CIDR6": 150 | // 处理 IP-CIDR,1.2.3.4/24 151 | parseIPLine(ruleValue, ipRanges) 152 | } 153 | return 154 | } 155 | 156 | // 2. 尝试解析纯 CIDR 或 IP 157 | parseIPLine(line, ipRanges) 158 | } 159 | 160 | func parseIPLine(line string, list *[]IPRange) { 161 | // 移除可能的引号 162 | line = strings.Trim(line, "'\"") 163 | 164 | _, ipNet, err := net.ParseCIDR(line) 165 | if err != nil { 166 | // 尝试作为单 IP 167 | ip := net.ParseIP(line) 168 | if ip != nil { 169 | val := ipToUint32(ip) 170 | *list = append(*list, IPRange{Start: val, End: val}) 171 | } 172 | return 173 | } 174 | start := ipToUint32(ipNet.IP) 175 | mask := binary.BigEndian.Uint32(ipNet.Mask) 176 | end := start | (^mask) 177 | *list = append(*list, IPRange{Start: start, End: end}) 178 | } 179 | 180 | // IsCN 检查目标是否匹配 CN 规则 (域名优先,其次 IP) 181 | // host 可以是域名或 IP 字符串 182 | func (m *Manager) IsCN(host string, ip net.IP) bool { 183 | m.mu.RLock() 184 | defer m.mu.RUnlock() 185 | 186 | // 0. Check if it's a local network address - always treat as "CN" (local) 187 | if m.isLocalNetwork(ip) { 188 | return true 189 | } 190 | 191 | // 1. Domain matching 192 | if ip == nil || (len(host) > 0 && host != ip.String()) { 193 | // This is a domain 194 | domain := strings.TrimSuffix(host, ".") // Remove trailing dot 195 | 196 | // Exact match 197 | if _, ok := m.domainExact[domain]; ok { 198 | return true 199 | } 200 | 201 | // Suffix matching 202 | // Strategy: Check level by level. E.g., www.baidu.com -> check www.baidu.com, baidu.com, com 203 | parts := strings.Split(domain, ".") 204 | for i := 0; i < len(parts); i++ { 205 | suffix := strings.Join(parts[i:], ".") 206 | if _, ok := m.domainSuffix[suffix]; ok { 207 | return true 208 | } 209 | } 210 | } 211 | 212 | // 2. IP matching 213 | if ip != nil { 214 | ip4 := ip.To4() 215 | if ip4 == nil { 216 | return false // IPv6 not supported for direct connection rules, default proxy 217 | } 218 | val := ipToUint32(ip4) 219 | 220 | idx := sort.Search(len(m.ipRanges), func(i int) bool { 221 | return m.ipRanges[i].End >= val 222 | }) 223 | 224 | if idx < len(m.ipRanges) && m.ipRanges[idx].Start <= val { 225 | return true 226 | } 227 | } 228 | 229 | return false 230 | } 231 | 232 | func ipToUint32(ip net.IP) uint32 { 233 | if len(ip) == 16 { 234 | return binary.BigEndian.Uint32(ip[12:16]) 235 | } 236 | return binary.BigEndian.Uint32(ip) 237 | } 238 | 239 | func mergeRanges(ranges []IPRange) []IPRange { 240 | if len(ranges) == 0 { 241 | return nil 242 | } 243 | sort.Slice(ranges, func(i, j int) bool { 244 | return ranges[i].Start < ranges[j].Start 245 | }) 246 | var result []IPRange 247 | current := ranges[0] 248 | for i := 1; i < len(ranges); i++ { 249 | next := ranges[i] 250 | if current.End >= next.Start-1 { 251 | if next.End > current.End { 252 | current.End = next.End 253 | } 254 | } else { 255 | result = append(result, current) 256 | current = next 257 | } 258 | } 259 | result = append(result, current) 260 | return result 261 | } 262 | 263 | func (m *Manager) isLocalNetwork(ip net.IP) bool { 264 | if ip == nil { 265 | return false 266 | } 267 | 268 | ip4 := ip.To4() 269 | if ip4 == nil { 270 | // For IPv6, check if it's loopback or link-local 271 | return ip.IsLoopback() || ip.IsLinkLocalUnicast() 272 | } 273 | 274 | val := ipToUint32(ip4) 275 | 276 | // Check against common LAN ranges 277 | return (val >= lanRange1Start && val <= lanRange1End) || // 10.0.0.0/8 278 | (val >= lanRange2Start && val <= lanRange2End) || // 172.16.0.0/12 279 | (val >= lanRange3Start && val <= lanRange3End) || // 192.168.0.0/16 280 | (val >= lanRange4Start && val <= lanRange4End) || // 127.0.0.0/8 281 | ip.IsLoopback() 282 | } 283 | -------------------------------------------------------------------------------- /apis/client.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) 2025 by ふたい 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | 17 | In addition, no derivative work may use the name or imply association 18 | with this application without prior consent. 19 | */ 20 | package apis 21 | 22 | import ( 23 | "context" 24 | "crypto/rand" 25 | "crypto/sha256" 26 | "encoding/binary" 27 | "fmt" 28 | "net" 29 | "strings" 30 | "time" 31 | 32 | "github.com/saba-futai/sudoku/internal/protocol" 33 | "github.com/saba-futai/sudoku/pkg/crypto" 34 | "github.com/saba-futai/sudoku/pkg/dnsutil" 35 | "github.com/saba-futai/sudoku/pkg/obfs/httpmask" 36 | "github.com/saba-futai/sudoku/pkg/obfs/sudoku" 37 | ) 38 | 39 | // Dial 建立一条到 Sudoku 服务器的隧道,并请求连接到 cfg.TargetAddress 40 | // 41 | // 参数: 42 | // - ctx: 用于控制连接建立的上下文(可以设置超时或取消) 43 | // - cfg: 协议配置,必须包含 Table、Key、ServerAddress、TargetAddress 等字段 44 | // 45 | // 返回值: 46 | // - net.Conn: 已经完成握手的加密隧道连接,可直接用于应用层数据传输 47 | // - error: 任何阶段失败都会返回错误 48 | // 49 | // 协议流程: 50 | // 1. 建立到服务器的 TCP 连接 51 | // 2. 发送 HTTP POST 伪装头 52 | // 3. 包装 Sudoku 混淆层 53 | // 4. 包装 AEAD 加密层 54 | // 5. 发送握手数据(时间戳 + 随机数) 55 | // 6. 发送目标地址 56 | // 57 | // 错误条件: 58 | // - TCP 连接失败 59 | // - 配置参数无效 (Table 为 nil 等) 60 | // - 写入 HTTP 伪装头失败 61 | // - 加密层初始化失败 62 | // - 握手数据发送失败 63 | // - 目标地址发送失败 64 | // 65 | // 使用示例: 66 | // 67 | // cfg := &ProtocolConfig{ 68 | // ServerAddress: "0.0.0.0:8443", 69 | // TargetAddress: "google.com:443", 70 | // Key: "my-secret-key", 71 | // AEADMethod: "chacha20-poly1305", 72 | // Table: sudoku.NewTableWithCustom("my-seed", "prefer_entropy", "xpxvvpvv"), 73 | // PaddingMin: 10, 74 | // PaddingMax: 30, 75 | // } 76 | // 77 | // ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 78 | // defer cancel() 79 | // 80 | // conn, err := apis.Dial(ctx, cfg) 81 | // if err != nil { 82 | // log.Fatal(err) 83 | // } 84 | // defer conn.Close() 85 | // 86 | // // 现在可以直接使用 conn 进行读写 87 | // conn.Write([]byte("Hello")) 88 | func buildHandshakePayload(key string) [16]byte { 89 | var payload [16]byte 90 | binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Unix())) 91 | hash := sha256.Sum256([]byte(key)) 92 | copy(payload[8:], hash[:8]) 93 | return payload 94 | } 95 | 96 | func pickClientTable(cfg *ProtocolConfig) (*sudoku.Table, byte, error) { 97 | candidates := cfg.tableCandidates() 98 | if len(candidates) == 0 { 99 | return nil, 0, fmt.Errorf("no table configured") 100 | } 101 | if len(candidates) == 1 { 102 | return candidates[0], 0, nil 103 | } 104 | var b [1]byte 105 | if _, err := rand.Read(b[:]); err != nil { 106 | return nil, 0, fmt.Errorf("random table pick failed: %w", err) 107 | } 108 | idx := int(b[0]) % len(candidates) 109 | return candidates[idx], byte(idx), nil 110 | } 111 | 112 | func wrapClientConn(rawConn net.Conn, cfg *ProtocolConfig, table *sudoku.Table) (net.Conn, error) { 113 | obfsConn := buildClientObfsConn(rawConn, cfg, table) 114 | seed := cfg.Key 115 | if recoveredFromKey, err := crypto.RecoverPublicKey(cfg.Key); err == nil { 116 | seed = crypto.EncodePoint(recoveredFromKey) 117 | } 118 | cConn, err := crypto.NewAEADConn(obfsConn, seed, cfg.AEADMethod) 119 | if err != nil { 120 | rawConn.Close() 121 | return nil, fmt.Errorf("setup crypto failed: %w", err) 122 | } 123 | return cConn, nil 124 | } 125 | 126 | func Dial(ctx context.Context, cfg *ProtocolConfig) (net.Conn, error) { 127 | baseConn, err := establishBaseConn(ctx, cfg, func(c *ProtocolConfig) error { return c.ValidateClient() }) 128 | if err != nil { 129 | return nil, err 130 | } 131 | 132 | if err := protocol.WriteAddress(baseConn, cfg.TargetAddress); err != nil { 133 | baseConn.Close() 134 | return nil, fmt.Errorf("send target address failed: %w", err) 135 | } 136 | 137 | return baseConn, nil 138 | } 139 | 140 | func establishBaseConn(ctx context.Context, cfg *ProtocolConfig, validate func(*ProtocolConfig) error) (net.Conn, error) { 141 | if cfg == nil { 142 | return nil, fmt.Errorf("config is required") 143 | } 144 | if err := validate(cfg); err != nil { 145 | return nil, fmt.Errorf("invalid config: %w", err) 146 | } 147 | 148 | // CDN-capable HTTP tunnel modes. 149 | if !cfg.DisableHTTPMask { 150 | switch strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) { 151 | case "xhttp", "pht", "auto": 152 | rawConn, err := httpmask.DialTunnel(ctx, cfg.ServerAddress, httpmask.TunnelDialOptions{ 153 | Mode: cfg.HTTPMaskMode, 154 | TLSEnabled: cfg.HTTPMaskTLSEnabled, 155 | HostOverride: cfg.HTTPMaskHost, 156 | }) 157 | if err != nil { 158 | return nil, fmt.Errorf("dial http tunnel failed: %w", err) 159 | } 160 | 161 | success := false 162 | defer func() { 163 | if !success { 164 | rawConn.Close() 165 | } 166 | }() 167 | 168 | table, tableID, err := pickClientTable(cfg) 169 | if err != nil { 170 | return nil, err 171 | } 172 | 173 | cConn, err := wrapClientConn(rawConn, cfg, table) 174 | if err != nil { 175 | return nil, err 176 | } 177 | 178 | handshake := buildHandshakePayload(cfg.Key) 179 | if len(cfg.tableCandidates()) > 1 { 180 | handshake[15] = tableID 181 | } 182 | if _, err := cConn.Write(handshake[:]); err != nil { 183 | cConn.Close() 184 | return nil, fmt.Errorf("send handshake failed: %w", err) 185 | } 186 | 187 | if _, err := cConn.Write([]byte{downlinkMode(cfg)}); err != nil { 188 | cConn.Close() 189 | return nil, fmt.Errorf("send downlink mode failed: %w", err) 190 | } 191 | 192 | success = true 193 | return cConn, nil 194 | } 195 | } 196 | 197 | resolvedAddr, err := dnsutil.ResolveWithCache(ctx, cfg.ServerAddress) 198 | if err != nil { 199 | return nil, fmt.Errorf("resolve server address failed: %w", err) 200 | } 201 | 202 | var d net.Dialer 203 | rawConn, err := d.DialContext(ctx, "tcp", resolvedAddr) 204 | if err != nil { 205 | return nil, fmt.Errorf("dial tcp failed: %w", err) 206 | } 207 | 208 | success := false 209 | defer func() { 210 | if !success { 211 | rawConn.Close() 212 | } 213 | }() 214 | 215 | if !cfg.DisableHTTPMask { 216 | if err := httpmask.WriteRandomRequestHeader(rawConn, cfg.ServerAddress); err != nil { 217 | return nil, fmt.Errorf("write http mask failed: %w", err) 218 | } 219 | } 220 | 221 | table, tableID, err := pickClientTable(cfg) 222 | if err != nil { 223 | return nil, err 224 | } 225 | 226 | cConn, err := wrapClientConn(rawConn, cfg, table) 227 | if err != nil { 228 | return nil, err 229 | } 230 | 231 | handshake := buildHandshakePayload(cfg.Key) 232 | if len(cfg.tableCandidates()) > 1 { 233 | handshake[15] = tableID 234 | } 235 | if _, err := cConn.Write(handshake[:]); err != nil { 236 | cConn.Close() 237 | return nil, fmt.Errorf("send handshake failed: %w", err) 238 | } 239 | 240 | if _, err := cConn.Write([]byte{downlinkMode(cfg)}); err != nil { 241 | cConn.Close() 242 | return nil, fmt.Errorf("send downlink mode failed: %w", err) 243 | } 244 | 245 | success = true 246 | return cConn, nil 247 | } 248 | 249 | func validateUoTConfig(cfg *ProtocolConfig) error { 250 | if cfg == nil { 251 | return fmt.Errorf("config is required") 252 | } 253 | if cfg.ServerAddress == "" { 254 | return fmt.Errorf("ServerAddress cannot be empty") 255 | } 256 | return cfg.Validate() 257 | } 258 | --------------------------------------------------------------------------------