mirror of
https://github.com/shtorm-7/sing-box-extended.git
synced 2026-08-04 12:55:17 +03:00
Add OpenVPN, TrustTunnel, Sudoku, inbound managers. Fixes
This commit is contained in:
100
transport/sudoku/address.go
Normal file
100
transport/sudoku/address.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func EncodeAddress(rawAddr string) ([]byte, error) {
|
||||
host, portStr, err := net.SplitHostPort(rawAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
portInt, err := strconv.ParseUint(portStr, 10, 16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var buf []byte
|
||||
if i := strings.IndexByte(host, '%'); i >= 0 {
|
||||
// Zone identifiers are not representable in SOCKS5 IPv6 address encoding.
|
||||
host = host[:i]
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
buf = append(buf, 0x01) // IPv4
|
||||
buf = append(buf, ip4...)
|
||||
} else {
|
||||
buf = append(buf, 0x04) // IPv6
|
||||
ip16 := ip.To16()
|
||||
if ip16 == nil {
|
||||
return nil, fmt.Errorf("invalid ipv6: %q", host)
|
||||
}
|
||||
buf = append(buf, ip16...)
|
||||
}
|
||||
} else {
|
||||
if len(host) > 255 {
|
||||
return nil, fmt.Errorf("domain too long")
|
||||
}
|
||||
buf = append(buf, 0x03) // domain
|
||||
buf = append(buf, byte(len(host)))
|
||||
buf = append(buf, host...)
|
||||
}
|
||||
|
||||
var portBytes [2]byte
|
||||
binary.BigEndian.PutUint16(portBytes[:], uint16(portInt))
|
||||
buf = append(buf, portBytes[:]...)
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func DecodeAddress(r io.Reader) (string, error) {
|
||||
var atyp [1]byte
|
||||
if _, err := io.ReadFull(r, atyp[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch atyp[0] {
|
||||
case 0x01: // IPv4
|
||||
var ipBuf [net.IPv4len]byte
|
||||
if _, err := io.ReadFull(r, ipBuf[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var portBuf [2]byte
|
||||
if _, err := io.ReadFull(r, portBuf[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return net.JoinHostPort(net.IP(ipBuf[:]).String(), fmt.Sprint(binary.BigEndian.Uint16(portBuf[:]))), nil
|
||||
case 0x04: // IPv6
|
||||
var ipBuf [net.IPv6len]byte
|
||||
if _, err := io.ReadFull(r, ipBuf[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var portBuf [2]byte
|
||||
if _, err := io.ReadFull(r, portBuf[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return net.JoinHostPort(net.IP(ipBuf[:]).String(), fmt.Sprint(binary.BigEndian.Uint16(portBuf[:]))), nil
|
||||
case 0x03: // domain
|
||||
var lengthBuf [1]byte
|
||||
if _, err := io.ReadFull(r, lengthBuf[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
l := int(lengthBuf[0])
|
||||
hostBuf := make([]byte, l)
|
||||
if _, err := io.ReadFull(r, hostBuf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var portBuf [2]byte
|
||||
if _, err := io.ReadFull(r, portBuf[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return net.JoinHostPort(string(hostBuf), fmt.Sprint(binary.BigEndian.Uint16(portBuf[:]))), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown address type: %d", atyp[0])
|
||||
}
|
||||
}
|
||||
212
transport/sudoku/config.go
Normal file
212
transport/sudoku/config.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/sing-box/transport/sudoku/obfs/sudoku"
|
||||
)
|
||||
|
||||
// ProtocolConfig defines the configuration for the Sudoku protocol stack.
|
||||
// It is intentionally kept close to the upstream Sudoku project to ensure wire compatibility.
|
||||
type ProtocolConfig struct {
|
||||
// Client-only: "host:port".
|
||||
ServerAddress string
|
||||
|
||||
// Pre-shared key (or ED25519 key material) used to derive crypto and tables.
|
||||
Key string
|
||||
|
||||
// "aes-128-gcm", "chacha20-poly1305", or "none".
|
||||
AEADMethod string
|
||||
|
||||
// Table is the single obfuscation table to use when table rotation is disabled.
|
||||
Table *sudoku.Table
|
||||
|
||||
// Tables is an optional candidate set for table rotation.
|
||||
// If provided (len>0), the client will pick one table per connection and the server will
|
||||
// probe the handshake to detect which one was used, keeping the handshake format unchanged.
|
||||
// When Tables is set, Table may be nil.
|
||||
Tables []*sudoku.Table
|
||||
|
||||
// Padding insertion ratio (0-100). Must satisfy PaddingMax >= PaddingMin.
|
||||
PaddingMin int
|
||||
PaddingMax int
|
||||
|
||||
// EnablePureDownlink enables the pure Sudoku downlink mode.
|
||||
// When false, the connection uses the bandwidth-optimized packed downlink.
|
||||
EnablePureDownlink bool
|
||||
|
||||
// Client-only: final target "host:port".
|
||||
TargetAddress string
|
||||
|
||||
// Server-side handshake timeout (seconds).
|
||||
HandshakeTimeoutSeconds int
|
||||
|
||||
// DisableHTTPMask disables all HTTP camouflage layers.
|
||||
DisableHTTPMask bool
|
||||
|
||||
// HTTPMaskMode controls how the HTTP layer behaves:
|
||||
// - "legacy": write a fake HTTP/1.1 header then switch to raw stream (default, not CDN-compatible)
|
||||
// - "stream": real HTTP tunnel (split-stream), CDN-compatible
|
||||
// - "poll": plain HTTP tunnel (authorize/push/pull), strong restricted-network pass-through
|
||||
// - "auto": try stream then fall back to poll
|
||||
// - "ws": WebSocket tunnel (GET upgrade), CDN-friendly
|
||||
HTTPMaskMode string
|
||||
|
||||
// HTTPMaskTLSEnabled enables HTTPS for HTTP tunnel modes (client-side).
|
||||
// If false, the tunnel uses HTTP (no port-based inference).
|
||||
HTTPMaskTLSEnabled bool
|
||||
|
||||
// HTTPMaskHost optionally overrides the HTTP Host header / SNI host for HTTP tunnel modes (client-side).
|
||||
HTTPMaskHost string
|
||||
|
||||
// HTTPMaskPathRoot optionally prefixes all HTTP mask paths with a first-level segment.
|
||||
// Example: "aabbcc" => "/aabbcc/session", "/aabbcc/api/v1/upload", ...
|
||||
HTTPMaskPathRoot string
|
||||
|
||||
// HTTPMaskMultiplex controls multiplex behavior when HTTPMask tunnel modes are enabled:
|
||||
// - "off": disable reuse; each Dial establishes its own HTTPMask tunnel
|
||||
// - "auto": reuse underlying HTTP connections across multiple tunnel dials (HTTP/1.1 keep-alive / HTTP/2)
|
||||
// - "on": enable "single tunnel, multi-target" mux (Sudoku-level multiplex; Dial behaves like "auto" otherwise)
|
||||
HTTPMaskMultiplex string
|
||||
}
|
||||
|
||||
func (c *ProtocolConfig) Validate() error {
|
||||
if c.Table == nil && len(c.Tables) == 0 {
|
||||
return fmt.Errorf("table cannot be nil (or provide tables)")
|
||||
}
|
||||
for i, t := range c.Tables {
|
||||
if t == nil {
|
||||
return fmt.Errorf("tables[%d] cannot be nil", i)
|
||||
}
|
||||
}
|
||||
|
||||
if c.Key == "" {
|
||||
return fmt.Errorf("key cannot be empty")
|
||||
}
|
||||
|
||||
switch c.AEADMethod {
|
||||
case "aes-128-gcm", "chacha20-poly1305", "none":
|
||||
default:
|
||||
return fmt.Errorf("invalid aead-method: %s, must be one of: aes-128-gcm, chacha20-poly1305, none", c.AEADMethod)
|
||||
}
|
||||
|
||||
if c.PaddingMin < 0 || c.PaddingMin > 100 {
|
||||
return fmt.Errorf("padding-min must be between 0 and 100, got %d", c.PaddingMin)
|
||||
}
|
||||
if c.PaddingMax < 0 || c.PaddingMax > 100 {
|
||||
return fmt.Errorf("padding-max must be between 0 and 100, got %d", c.PaddingMax)
|
||||
}
|
||||
if c.PaddingMax < c.PaddingMin {
|
||||
return fmt.Errorf("padding-max (%d) must be >= padding-min (%d)", c.PaddingMax, c.PaddingMin)
|
||||
}
|
||||
|
||||
if c.HandshakeTimeoutSeconds < 0 {
|
||||
return fmt.Errorf("handshake-timeout must be >= 0, got %d", c.HandshakeTimeoutSeconds)
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(c.HTTPMaskMode)) {
|
||||
case "", "legacy", "stream", "poll", "auto", "ws":
|
||||
default:
|
||||
return fmt.Errorf("invalid http-mask-mode: %s, must be one of: legacy, stream, poll, auto, ws", c.HTTPMaskMode)
|
||||
}
|
||||
|
||||
if v := strings.TrimSpace(c.HTTPMaskPathRoot); v != "" {
|
||||
v = strings.Trim(v, "/")
|
||||
if v == "" || strings.Contains(v, "/") {
|
||||
return fmt.Errorf("invalid http-mask-path-root: must be a single path segment")
|
||||
}
|
||||
for i := 0; i < len(v); i++ {
|
||||
ch := v[i]
|
||||
switch {
|
||||
case ch >= 'a' && ch <= 'z':
|
||||
case ch >= 'A' && ch <= 'Z':
|
||||
case ch >= '0' && ch <= '9':
|
||||
case ch == '_' || ch == '-':
|
||||
default:
|
||||
return fmt.Errorf("invalid http-mask-path-root: contains invalid character %q", ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(c.HTTPMaskMultiplex)) {
|
||||
case "", "off", "auto", "on":
|
||||
default:
|
||||
return fmt.Errorf("invalid http-mask-multiplex: %s, must be one of: off, auto, on", c.HTTPMaskMultiplex)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ProtocolConfig) ValidateClient() error {
|
||||
if err := c.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.ServerAddress == "" {
|
||||
return fmt.Errorf("server address cannot be empty")
|
||||
}
|
||||
if c.TargetAddress == "" {
|
||||
return fmt.Errorf("target address cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DefaultConfig() *ProtocolConfig {
|
||||
return &ProtocolConfig{
|
||||
AEADMethod: "chacha20-poly1305",
|
||||
PaddingMin: 10,
|
||||
PaddingMax: 30,
|
||||
EnablePureDownlink: true,
|
||||
HandshakeTimeoutSeconds: 5,
|
||||
HTTPMaskMode: "legacy",
|
||||
HTTPMaskMultiplex: "off",
|
||||
}
|
||||
}
|
||||
|
||||
func DerefInt(v *int, def int) int {
|
||||
if v == nil {
|
||||
return def
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
func DerefBool(v *bool, def bool) bool {
|
||||
if v == nil {
|
||||
return def
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// ResolvePadding applies defaults and keeps min/max consistent when only one side is provided.
|
||||
func ResolvePadding(min, max *int, defMin, defMax int) (int, int) {
|
||||
paddingMin := DerefInt(min, defMin)
|
||||
paddingMax := DerefInt(max, defMax)
|
||||
switch {
|
||||
case min == nil && max != nil && paddingMax < paddingMin:
|
||||
paddingMin = paddingMax
|
||||
case max == nil && min != nil && paddingMax < paddingMin:
|
||||
paddingMax = paddingMin
|
||||
}
|
||||
return paddingMin, paddingMax
|
||||
}
|
||||
|
||||
func NormalizeTableType(tableType string) (string, error) {
|
||||
normalized, err := sudoku.NormalizeASCIIMode(tableType)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("table-type must be prefer_ascii, prefer_entropy, up_ascii_down_entropy, or up_entropy_down_ascii")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func (c *ProtocolConfig) tableCandidates() []*sudoku.Table {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if len(c.Tables) > 0 {
|
||||
return c.Tables
|
||||
}
|
||||
if c.Table != nil {
|
||||
return []*sudoku.Table{c.Table}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
150
transport/sudoku/crypto/aead.go
Normal file
150
transport/sudoku/crypto/aead.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
)
|
||||
|
||||
type AEADConn struct {
|
||||
net.Conn
|
||||
aead cipher.AEAD
|
||||
readBuf bytes.Buffer
|
||||
nonceSize int
|
||||
}
|
||||
|
||||
func (cc *AEADConn) CloseWrite() error {
|
||||
if cc == nil || cc.Conn == nil {
|
||||
return nil
|
||||
}
|
||||
if cw, ok := cc.Conn.(interface{ CloseWrite() error }); ok {
|
||||
return cw.CloseWrite()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cc *AEADConn) CloseRead() error {
|
||||
if cc == nil || cc.Conn == nil {
|
||||
return nil
|
||||
}
|
||||
if cr, ok := cc.Conn.(interface{ CloseRead() error }); ok {
|
||||
return cr.CloseRead()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewAEADConn(c net.Conn, key string, method string) (*AEADConn, error) {
|
||||
if method == "none" {
|
||||
return &AEADConn{Conn: c, aead: nil}, nil
|
||||
}
|
||||
|
||||
h := sha256.New()
|
||||
h.Write([]byte(key))
|
||||
keyBytes := h.Sum(nil)
|
||||
|
||||
var aead cipher.AEAD
|
||||
var err error
|
||||
|
||||
switch method {
|
||||
case "aes-128-gcm":
|
||||
block, _ := aes.NewCipher(keyBytes[:16])
|
||||
aead, err = cipher.NewGCM(block)
|
||||
case "chacha20-poly1305":
|
||||
aead, err = chacha20poly1305.New(keyBytes)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported cipher: %s", method)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AEADConn{
|
||||
Conn: c,
|
||||
aead: aead,
|
||||
nonceSize: aead.NonceSize(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (cc *AEADConn) Write(p []byte) (int, error) {
|
||||
if cc.aead == nil {
|
||||
return cc.Conn.Write(p)
|
||||
}
|
||||
|
||||
maxPayload := 65535 - cc.nonceSize - cc.aead.Overhead()
|
||||
totalWritten := 0
|
||||
var frameBuf bytes.Buffer
|
||||
header := make([]byte, 2)
|
||||
nonce := make([]byte, cc.nonceSize)
|
||||
|
||||
for len(p) > 0 {
|
||||
chunkSize := len(p)
|
||||
if chunkSize > maxPayload {
|
||||
chunkSize = maxPayload
|
||||
}
|
||||
chunk := p[:chunkSize]
|
||||
p = p[chunkSize:]
|
||||
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return totalWritten, err
|
||||
}
|
||||
|
||||
ciphertext := cc.aead.Seal(nil, nonce, chunk, nil)
|
||||
frameLen := len(nonce) + len(ciphertext)
|
||||
binary.BigEndian.PutUint16(header, uint16(frameLen))
|
||||
|
||||
frameBuf.Reset()
|
||||
frameBuf.Write(header)
|
||||
frameBuf.Write(nonce)
|
||||
frameBuf.Write(ciphertext)
|
||||
|
||||
if _, err := cc.Conn.Write(frameBuf.Bytes()); err != nil {
|
||||
return totalWritten, err
|
||||
}
|
||||
totalWritten += chunkSize
|
||||
}
|
||||
return totalWritten, nil
|
||||
}
|
||||
|
||||
func (cc *AEADConn) Read(p []byte) (int, error) {
|
||||
if cc.aead == nil {
|
||||
return cc.Conn.Read(p)
|
||||
}
|
||||
|
||||
if cc.readBuf.Len() > 0 {
|
||||
return cc.readBuf.Read(p)
|
||||
}
|
||||
|
||||
header := make([]byte, 2)
|
||||
if _, err := io.ReadFull(cc.Conn, header); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
frameLen := int(binary.BigEndian.Uint16(header))
|
||||
|
||||
body := make([]byte, frameLen)
|
||||
if _, err := io.ReadFull(cc.Conn, body); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if len(body) < cc.nonceSize {
|
||||
return 0, errors.New("frame too short")
|
||||
}
|
||||
nonce := body[:cc.nonceSize]
|
||||
ciphertext := body[cc.nonceSize:]
|
||||
|
||||
plaintext, err := cc.aead.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return 0, errors.New("decryption failed")
|
||||
}
|
||||
|
||||
cc.readBuf.Write(plaintext)
|
||||
return cc.readBuf.Read(p)
|
||||
}
|
||||
116
transport/sudoku/crypto/ed25519.go
Normal file
116
transport/sudoku/crypto/ed25519.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"filippo.io/edwards25519"
|
||||
)
|
||||
|
||||
// KeyPair holds the scalar private key and point public key
|
||||
type KeyPair struct {
|
||||
Private *edwards25519.Scalar
|
||||
Public *edwards25519.Point
|
||||
}
|
||||
|
||||
// GenerateMasterKey generates a random master private key (scalar) and its public key (point)
|
||||
func GenerateMasterKey() (*KeyPair, error) {
|
||||
// 1. Generate random scalar x (32 bytes)
|
||||
var seed [64]byte
|
||||
if _, err := rand.Read(seed[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
x, err := edwards25519.NewScalar().SetUniformBytes(seed[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. Calculate Public Key P = x * G
|
||||
P := new(edwards25519.Point).ScalarBaseMult(x)
|
||||
|
||||
return &KeyPair{Private: x, Public: P}, nil
|
||||
}
|
||||
|
||||
// SplitPrivateKey takes a master private key x and returns a new random split key (r, k)
|
||||
// such that x = r + k (mod L).
|
||||
// Returns hex encoded string of r || k (64 bytes)
|
||||
func SplitPrivateKey(x *edwards25519.Scalar) (string, error) {
|
||||
// 1. Generate random r (32 bytes)
|
||||
var seed [64]byte
|
||||
if _, err := rand.Read(seed[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
r, err := edwards25519.NewScalar().SetUniformBytes(seed[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 2. Calculate k = x - r (mod L)
|
||||
k := new(edwards25519.Scalar).Subtract(x, r)
|
||||
|
||||
// 3. Encode r and k
|
||||
rBytes := r.Bytes()
|
||||
kBytes := k.Bytes()
|
||||
|
||||
full := make([]byte, 64)
|
||||
copy(full[:32], rBytes)
|
||||
copy(full[32:], kBytes)
|
||||
|
||||
return hex.EncodeToString(full), nil
|
||||
}
|
||||
|
||||
// RecoverPublicKey takes a split private key (r, k) or a master private key (x)
|
||||
// and returns the public key P.
|
||||
// Input can be:
|
||||
// - 32 bytes hex (Master Scalar x)
|
||||
// - 64 bytes hex (Split Key r || k)
|
||||
func RecoverPublicKey(keyHex string) (*edwards25519.Point, error) {
|
||||
keyBytes, err := hex.DecodeString(keyHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid hex: %w", err)
|
||||
}
|
||||
|
||||
if len(keyBytes) == 32 {
|
||||
// Master Key x
|
||||
x, err := edwards25519.NewScalar().SetCanonicalBytes(keyBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid scalar: %w", err)
|
||||
}
|
||||
return new(edwards25519.Point).ScalarBaseMult(x), nil
|
||||
}
|
||||
if len(keyBytes) == 64 {
|
||||
// Split Key r || k
|
||||
rBytes := keyBytes[:32]
|
||||
kBytes := keyBytes[32:]
|
||||
|
||||
r, err := edwards25519.NewScalar().SetCanonicalBytes(rBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid scalar r: %w", err)
|
||||
}
|
||||
k, err := edwards25519.NewScalar().SetCanonicalBytes(kBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid scalar k: %w", err)
|
||||
}
|
||||
|
||||
// sum = r + k
|
||||
sum := new(edwards25519.Scalar).Add(r, k)
|
||||
|
||||
// P = sum * G
|
||||
return new(edwards25519.Point).ScalarBaseMult(sum), nil
|
||||
}
|
||||
|
||||
return nil, errors.New("invalid key length: must be 32 bytes (Master) or 64 bytes (Split)")
|
||||
}
|
||||
|
||||
// EncodePoint returns the hex string of the compressed point
|
||||
func EncodePoint(p *edwards25519.Point) string {
|
||||
return hex.EncodeToString(p.Bytes())
|
||||
}
|
||||
|
||||
// EncodeScalar returns the hex string of the scalar
|
||||
func EncodeScalar(s *edwards25519.Scalar) string {
|
||||
return hex.EncodeToString(s.Bytes())
|
||||
}
|
||||
452
transport/sudoku/crypto/record_conn.go
Normal file
452
transport/sudoku/crypto/record_conn.go
Normal file
@@ -0,0 +1,452 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
)
|
||||
|
||||
// KeyUpdateAfterBytes controls automatic key rotation based on plaintext bytes.
|
||||
// It is a package var (not config) to enable targeted tests with smaller thresholds.
|
||||
var KeyUpdateAfterBytes int64 = 32 << 20 // 32 MiB
|
||||
|
||||
const (
|
||||
recordHeaderSize = 12 // epoch(uint32) + seq(uint64) - also used as nonce+AAD.
|
||||
maxFrameBodySize = 65535
|
||||
)
|
||||
|
||||
type recordKeys struct {
|
||||
baseSend []byte
|
||||
baseRecv []byte
|
||||
}
|
||||
|
||||
// RecordConn is a framed AEAD net.Conn with:
|
||||
// - deterministic per-record nonce (epoch+seq)
|
||||
// - per-direction key rotation (epoch), driven by plaintext byte counters
|
||||
// - replay/out-of-order protection within the connection (strict seq check)
|
||||
//
|
||||
// Wire format per record:
|
||||
// - uint16 bodyLen
|
||||
// - header[12] = epoch(uint32 BE) || seq(uint64 BE) (plaintext)
|
||||
// - ciphertext = AEAD(header as nonce, plaintext, header as AAD)
|
||||
type RecordConn struct {
|
||||
net.Conn
|
||||
method string
|
||||
|
||||
writeMu sync.Mutex
|
||||
readMu sync.Mutex
|
||||
|
||||
keys recordKeys
|
||||
|
||||
sendAEAD cipher.AEAD
|
||||
sendAEADEpoch uint32
|
||||
|
||||
recvAEAD cipher.AEAD
|
||||
recvAEADEpoch uint32
|
||||
|
||||
// Send direction state.
|
||||
sendEpoch uint32
|
||||
sendSeq uint64
|
||||
sendBytes int64
|
||||
sendEpochUpdates uint32
|
||||
|
||||
// Receive direction state.
|
||||
recvEpoch uint32
|
||||
recvSeq uint64
|
||||
recvInitialized bool
|
||||
|
||||
readBuf bytes.Buffer
|
||||
|
||||
// writeFrame is a reusable buffer for [len||header||ciphertext] on the wire.
|
||||
// Guarded by writeMu.
|
||||
writeFrame []byte
|
||||
}
|
||||
|
||||
func (c *RecordConn) CloseWrite() error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if cw, ok := c.Conn.(interface{ CloseWrite() error }); ok {
|
||||
return cw.CloseWrite()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *RecordConn) CloseRead() error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if cr, ok := c.Conn.(interface{ CloseRead() error }); ok {
|
||||
return cr.CloseRead()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewRecordConn(conn net.Conn, method string, baseSend, baseRecv []byte) (*RecordConn, error) {
|
||||
if conn == nil {
|
||||
return nil, fmt.Errorf("nil conn")
|
||||
}
|
||||
method = normalizeAEADMethod(method)
|
||||
if method != "none" {
|
||||
if err := validateBaseKey(baseSend); err != nil {
|
||||
return nil, fmt.Errorf("invalid send base key: %w", err)
|
||||
}
|
||||
if err := validateBaseKey(baseRecv); err != nil {
|
||||
return nil, fmt.Errorf("invalid recv base key: %w", err)
|
||||
}
|
||||
}
|
||||
rc := &RecordConn{Conn: conn, method: method}
|
||||
rc.keys = recordKeys{baseSend: cloneBytes(baseSend), baseRecv: cloneBytes(baseRecv)}
|
||||
if err := rc.resetTrafficState(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
func (c *RecordConn) Rekey(baseSend, baseRecv []byte) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("nil conn")
|
||||
}
|
||||
if c.method != "none" {
|
||||
if err := validateBaseKey(baseSend); err != nil {
|
||||
return fmt.Errorf("invalid send base key: %w", err)
|
||||
}
|
||||
if err := validateBaseKey(baseRecv); err != nil {
|
||||
return fmt.Errorf("invalid recv base key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
c.writeMu.Lock()
|
||||
c.readMu.Lock()
|
||||
defer c.readMu.Unlock()
|
||||
defer c.writeMu.Unlock()
|
||||
|
||||
c.keys = recordKeys{baseSend: cloneBytes(baseSend), baseRecv: cloneBytes(baseRecv)}
|
||||
if err := c.resetTrafficState(); err != nil {
|
||||
return err
|
||||
}
|
||||
c.readBuf.Reset()
|
||||
|
||||
c.sendAEAD = nil
|
||||
c.recvAEAD = nil
|
||||
c.sendAEADEpoch = 0
|
||||
c.recvAEADEpoch = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *RecordConn) resetTrafficState() error {
|
||||
sendEpoch, sendSeq, err := randomRecordCounters()
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize record counters: %w", err)
|
||||
}
|
||||
c.sendEpoch = sendEpoch
|
||||
c.sendSeq = sendSeq
|
||||
c.sendBytes = 0
|
||||
c.sendEpochUpdates = 0
|
||||
c.recvEpoch = 0
|
||||
c.recvSeq = 0
|
||||
c.recvInitialized = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeAEADMethod(method string) string {
|
||||
switch method {
|
||||
case "", "chacha20-poly1305":
|
||||
return "chacha20-poly1305"
|
||||
case "aes-128-gcm", "none":
|
||||
return method
|
||||
default:
|
||||
return method
|
||||
}
|
||||
}
|
||||
|
||||
func validateBaseKey(b []byte) error {
|
||||
if len(b) < 32 {
|
||||
return fmt.Errorf("need at least 32 bytes, got %d", len(b))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneBytes(b []byte) []byte {
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]byte(nil), b...)
|
||||
}
|
||||
|
||||
func randomRecordCounters() (uint32, uint64, error) {
|
||||
epoch, err := randomNonZeroUint32()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
seq, err := randomNonZeroUint64()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return epoch, seq, nil
|
||||
}
|
||||
|
||||
func randomNonZeroUint32() (uint32, error) {
|
||||
var b [4]byte
|
||||
for {
|
||||
if _, err := io.ReadFull(rand.Reader, b[:]); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
v := binary.BigEndian.Uint32(b[:])
|
||||
if v != 0 && v != ^uint32(0) {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func randomNonZeroUint64() (uint64, error) {
|
||||
var b [8]byte
|
||||
for {
|
||||
if _, err := io.ReadFull(rand.Reader, b[:]); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
v := binary.BigEndian.Uint64(b[:])
|
||||
if v != 0 && v != ^uint64(0) {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *RecordConn) newAEADFor(base []byte, epoch uint32) (cipher.AEAD, error) {
|
||||
if c.method == "none" {
|
||||
return nil, nil
|
||||
}
|
||||
key := deriveEpochKey(base, epoch, c.method)
|
||||
switch c.method {
|
||||
case "aes-128-gcm":
|
||||
block, err := aes.NewCipher(key[:16])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.NonceSize() != recordHeaderSize {
|
||||
return nil, fmt.Errorf("unexpected gcm nonce size: %d", a.NonceSize())
|
||||
}
|
||||
return a, nil
|
||||
case "chacha20-poly1305":
|
||||
a, err := chacha20poly1305.New(key[:32])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.NonceSize() != recordHeaderSize {
|
||||
return nil, fmt.Errorf("unexpected chacha nonce size: %d", a.NonceSize())
|
||||
}
|
||||
return a, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported cipher: %s", c.method)
|
||||
}
|
||||
}
|
||||
|
||||
func deriveEpochKey(base []byte, epoch uint32, method string) []byte {
|
||||
var b [4]byte
|
||||
binary.BigEndian.PutUint32(b[:], epoch)
|
||||
mac := hmac.New(sha256.New, base)
|
||||
_, _ = mac.Write([]byte("sudoku-record:"))
|
||||
_, _ = mac.Write([]byte(method))
|
||||
_, _ = mac.Write(b[:])
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func (c *RecordConn) maybeBumpSendEpochLocked(addedPlain int) error {
|
||||
ku := atomic.LoadInt64(&KeyUpdateAfterBytes)
|
||||
if ku <= 0 || c.method == "none" {
|
||||
return nil
|
||||
}
|
||||
c.sendBytes += int64(addedPlain)
|
||||
threshold := ku * int64(c.sendEpochUpdates+1)
|
||||
if c.sendBytes < threshold {
|
||||
return nil
|
||||
}
|
||||
c.sendEpoch++
|
||||
c.sendEpochUpdates++
|
||||
nextSeq, err := randomNonZeroUint64()
|
||||
if err != nil {
|
||||
return fmt.Errorf("rotate record seq: %w", err)
|
||||
}
|
||||
c.sendSeq = nextSeq
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *RecordConn) validateRecvPosition(epoch uint32, seq uint64) error {
|
||||
if !c.recvInitialized {
|
||||
return nil
|
||||
}
|
||||
if epoch < c.recvEpoch {
|
||||
return fmt.Errorf("replayed epoch: got %d want >=%d", epoch, c.recvEpoch)
|
||||
}
|
||||
if epoch == c.recvEpoch && seq != c.recvSeq {
|
||||
return fmt.Errorf("out of order: epoch=%d got=%d want=%d", epoch, seq, c.recvSeq)
|
||||
}
|
||||
if epoch > c.recvEpoch {
|
||||
const maxJump = 8
|
||||
if epoch-c.recvEpoch > maxJump {
|
||||
return fmt.Errorf("epoch jump too large: got=%d want<=%d", epoch-c.recvEpoch, maxJump)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *RecordConn) markRecvPosition(epoch uint32, seq uint64) {
|
||||
c.recvEpoch = epoch
|
||||
c.recvSeq = seq + 1
|
||||
c.recvInitialized = true
|
||||
}
|
||||
|
||||
func (c *RecordConn) Write(p []byte) (int, error) {
|
||||
if c == nil || c.Conn == nil {
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
if c.method == "none" {
|
||||
return c.Conn.Write(p)
|
||||
}
|
||||
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
|
||||
total := 0
|
||||
for len(p) > 0 {
|
||||
if c.sendAEAD == nil || c.sendAEADEpoch != c.sendEpoch {
|
||||
a, err := c.newAEADFor(c.keys.baseSend, c.sendEpoch)
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
c.sendAEAD = a
|
||||
c.sendAEADEpoch = c.sendEpoch
|
||||
}
|
||||
aead := c.sendAEAD
|
||||
|
||||
maxPlain := maxFrameBodySize - recordHeaderSize - aead.Overhead()
|
||||
if maxPlain <= 0 {
|
||||
return total, errors.New("frame size too small")
|
||||
}
|
||||
n := len(p)
|
||||
if n > maxPlain {
|
||||
n = maxPlain
|
||||
}
|
||||
chunk := p[:n]
|
||||
p = p[n:]
|
||||
|
||||
var header [recordHeaderSize]byte
|
||||
binary.BigEndian.PutUint32(header[:4], c.sendEpoch)
|
||||
binary.BigEndian.PutUint64(header[4:], c.sendSeq)
|
||||
c.sendSeq++
|
||||
|
||||
cipherLen := n + aead.Overhead()
|
||||
bodyLen := recordHeaderSize + cipherLen
|
||||
frameLen := 2 + bodyLen
|
||||
if bodyLen > maxFrameBodySize {
|
||||
return total, errors.New("frame too large")
|
||||
}
|
||||
if cap(c.writeFrame) < frameLen {
|
||||
c.writeFrame = make([]byte, frameLen)
|
||||
}
|
||||
frame := c.writeFrame[:frameLen]
|
||||
binary.BigEndian.PutUint16(frame[:2], uint16(bodyLen))
|
||||
copy(frame[2:2+recordHeaderSize], header[:])
|
||||
|
||||
dst := frame[2+recordHeaderSize : 2+recordHeaderSize : frameLen]
|
||||
_ = aead.Seal(dst[:0], header[:], chunk, header[:])
|
||||
|
||||
if err := writeFull(c.Conn, frame); err != nil {
|
||||
return total, err
|
||||
}
|
||||
|
||||
total += n
|
||||
if err := c.maybeBumpSendEpochLocked(n); err != nil {
|
||||
return total, err
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (c *RecordConn) Read(p []byte) (int, error) {
|
||||
if c == nil || c.Conn == nil {
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
if c.method == "none" {
|
||||
return c.Conn.Read(p)
|
||||
}
|
||||
|
||||
c.readMu.Lock()
|
||||
defer c.readMu.Unlock()
|
||||
|
||||
if c.readBuf.Len() > 0 {
|
||||
return c.readBuf.Read(p)
|
||||
}
|
||||
|
||||
var lenBuf [2]byte
|
||||
if _, err := io.ReadFull(c.Conn, lenBuf[:]); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
bodyLen := int(binary.BigEndian.Uint16(lenBuf[:]))
|
||||
if bodyLen < recordHeaderSize {
|
||||
return 0, errors.New("frame too short")
|
||||
}
|
||||
if bodyLen > maxFrameBodySize {
|
||||
return 0, errors.New("frame too large")
|
||||
}
|
||||
|
||||
body := make([]byte, bodyLen)
|
||||
if _, err := io.ReadFull(c.Conn, body); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
header := body[:recordHeaderSize]
|
||||
ciphertext := body[recordHeaderSize:]
|
||||
|
||||
epoch := binary.BigEndian.Uint32(header[:4])
|
||||
seq := binary.BigEndian.Uint64(header[4:])
|
||||
|
||||
if err := c.validateRecvPosition(epoch, seq); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if c.recvAEAD == nil || c.recvAEADEpoch != epoch {
|
||||
a, err := c.newAEADFor(c.keys.baseRecv, epoch)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
c.recvAEAD = a
|
||||
c.recvAEADEpoch = epoch
|
||||
}
|
||||
aead := c.recvAEAD
|
||||
|
||||
plaintext, err := aead.Open(nil, header, ciphertext, header)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("decryption failed: epoch=%d seq=%d: %w", epoch, seq, err)
|
||||
}
|
||||
c.markRecvPosition(epoch, seq)
|
||||
|
||||
c.readBuf.Write(plaintext)
|
||||
return c.readBuf.Read(p)
|
||||
}
|
||||
|
||||
func writeFull(w io.Writer, b []byte) error {
|
||||
for len(b) > 0 {
|
||||
n, err := w.Write(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b = b[n:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
343
transport/sudoku/early_handshake.go
Normal file
343
transport/sudoku/early_handshake.go
Normal file
@@ -0,0 +1,343 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/transport/sudoku/crypto"
|
||||
httpmaskobfs "github.com/sagernet/sing-box/transport/sudoku/obfs/httpmask"
|
||||
sudokuobfs "github.com/sagernet/sing-box/transport/sudoku/obfs/sudoku"
|
||||
)
|
||||
|
||||
const earlyKIPHandshakeTTL = 60 * time.Second
|
||||
|
||||
type EarlyCodecConfig struct {
|
||||
PSK string
|
||||
AEAD string
|
||||
EnablePureDownlink bool
|
||||
PaddingMin int
|
||||
PaddingMax int
|
||||
}
|
||||
|
||||
type EarlyClientState struct {
|
||||
RequestPayload []byte
|
||||
|
||||
cfg EarlyCodecConfig
|
||||
table *sudokuobfs.Table
|
||||
nonce [kipHelloNonceSize]byte
|
||||
ephemeral *ecdh.PrivateKey
|
||||
sessionC2S []byte
|
||||
sessionS2C []byte
|
||||
responseSet bool
|
||||
}
|
||||
|
||||
type EarlyServerState struct {
|
||||
ResponsePayload []byte
|
||||
UserHash string
|
||||
|
||||
cfg EarlyCodecConfig
|
||||
table *sudokuobfs.Table
|
||||
sessionC2S []byte
|
||||
sessionS2C []byte
|
||||
}
|
||||
|
||||
type ReplayAllowFunc func(userHash string, nonce [kipHelloNonceSize]byte, now time.Time) bool
|
||||
|
||||
type earlyMemoryConn struct {
|
||||
reader *bytes.Reader
|
||||
write bytes.Buffer
|
||||
}
|
||||
|
||||
func newEarlyMemoryConn(readBuf []byte) *earlyMemoryConn {
|
||||
return &earlyMemoryConn{reader: bytes.NewReader(readBuf)}
|
||||
}
|
||||
|
||||
func (c *earlyMemoryConn) Read(p []byte) (int, error) {
|
||||
if c == nil || c.reader == nil {
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
return c.reader.Read(p)
|
||||
}
|
||||
|
||||
func (c *earlyMemoryConn) Write(p []byte) (int, error) {
|
||||
if c == nil {
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
return c.write.Write(p)
|
||||
}
|
||||
|
||||
func (c *earlyMemoryConn) Close() error { return nil }
|
||||
func (c *earlyMemoryConn) LocalAddr() net.Addr { return earlyDummyAddr("local") }
|
||||
func (c *earlyMemoryConn) RemoteAddr() net.Addr { return earlyDummyAddr("remote") }
|
||||
func (c *earlyMemoryConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *earlyMemoryConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *earlyMemoryConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
func (c *earlyMemoryConn) Written() []byte { return append([]byte(nil), c.write.Bytes()...) }
|
||||
|
||||
type earlyDummyAddr string
|
||||
|
||||
func (a earlyDummyAddr) Network() string { return string(a) }
|
||||
func (a earlyDummyAddr) String() string { return string(a) }
|
||||
|
||||
func buildEarlyClientObfsConn(raw net.Conn, cfg EarlyCodecConfig, table *sudokuobfs.Table) net.Conn {
|
||||
base := sudokuobfs.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false)
|
||||
downlinkReader := newClientDownlinkReader(raw, table, cfg.PaddingMin, cfg.PaddingMax, cfg.EnablePureDownlink)
|
||||
if downlinkReader == nil {
|
||||
return base
|
||||
}
|
||||
return newDirectionalConn(raw, downlinkReader, base)
|
||||
}
|
||||
|
||||
func buildEarlyServerObfsConn(raw net.Conn, cfg EarlyCodecConfig, table *sudokuobfs.Table) net.Conn {
|
||||
uplink := sudokuobfs.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false)
|
||||
downlinkWriter, closers := newServerDownlinkWriter(raw, table, cfg.PaddingMin, cfg.PaddingMax, cfg.EnablePureDownlink)
|
||||
if downlinkWriter == nil {
|
||||
return uplink
|
||||
}
|
||||
return newDirectionalConn(raw, uplink, downlinkWriter, closers...)
|
||||
}
|
||||
|
||||
func NewEarlyClientState(cfg EarlyCodecConfig, table *sudokuobfs.Table, tableHint uint32, hasTableHint bool, userHash [kipHelloUserHashSize]byte, feats uint32) (*EarlyClientState, error) {
|
||||
if table == nil {
|
||||
return nil, fmt.Errorf("nil table")
|
||||
}
|
||||
|
||||
curve := ecdh.X25519()
|
||||
ephemeral, err := curve.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ecdh generate failed: %w", err)
|
||||
}
|
||||
|
||||
var nonce [kipHelloNonceSize]byte
|
||||
if _, err := rand.Read(nonce[:]); err != nil {
|
||||
return nil, fmt.Errorf("nonce generate failed: %w", err)
|
||||
}
|
||||
|
||||
var clientPub [kipHelloPubSize]byte
|
||||
copy(clientPub[:], ephemeral.PublicKey().Bytes())
|
||||
hello := newKIPClientHello(userHash, nonce, clientPub, feats, tableHint, hasTableHint)
|
||||
|
||||
mem := newEarlyMemoryConn(nil)
|
||||
obfsConn := buildEarlyClientObfsConn(mem, cfg, table)
|
||||
pskC2S, pskS2C := derivePSKDirectionalBases(cfg.PSK)
|
||||
rc, err := crypto.NewRecordConn(obfsConn, cfg.AEAD, pskC2S, pskS2C)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client early crypto setup failed: %w", err)
|
||||
}
|
||||
if err := WriteKIPMessage(rc, KIPTypeClientHello, hello.EncodePayload()); err != nil {
|
||||
return nil, fmt.Errorf("write early client hello failed: %w", err)
|
||||
}
|
||||
|
||||
return &EarlyClientState{
|
||||
RequestPayload: mem.Written(),
|
||||
cfg: cfg,
|
||||
table: table,
|
||||
nonce: nonce,
|
||||
ephemeral: ephemeral,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *EarlyClientState) ProcessResponse(payload []byte) error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("nil client state")
|
||||
}
|
||||
|
||||
mem := newEarlyMemoryConn(payload)
|
||||
obfsConn := buildEarlyClientObfsConn(mem, s.cfg, s.table)
|
||||
pskC2S, pskS2C := derivePSKDirectionalBases(s.cfg.PSK)
|
||||
rc, err := crypto.NewRecordConn(obfsConn, s.cfg.AEAD, pskC2S, pskS2C)
|
||||
if err != nil {
|
||||
return fmt.Errorf("client early crypto setup failed: %w", err)
|
||||
}
|
||||
|
||||
msg, err := ReadKIPMessage(rc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read early server hello failed: %w", err)
|
||||
}
|
||||
if msg.Type != KIPTypeServerHello {
|
||||
return fmt.Errorf("unexpected early handshake message: %d", msg.Type)
|
||||
}
|
||||
sh, err := DecodeKIPServerHelloPayload(msg.Payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode early server hello failed: %w", err)
|
||||
}
|
||||
if sh.Nonce != s.nonce {
|
||||
return fmt.Errorf("early handshake nonce mismatch")
|
||||
}
|
||||
|
||||
shared, err := x25519SharedSecret(s.ephemeral, sh.ServerPub[:])
|
||||
if err != nil {
|
||||
return fmt.Errorf("ecdh failed: %w", err)
|
||||
}
|
||||
s.sessionC2S, s.sessionS2C, err = deriveSessionDirectionalBases(s.cfg.PSK, shared, s.nonce)
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive session keys failed: %w", err)
|
||||
}
|
||||
s.responseSet = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EarlyClientState) WrapConn(raw net.Conn) (net.Conn, error) {
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("nil client state")
|
||||
}
|
||||
if !s.responseSet {
|
||||
return nil, fmt.Errorf("early handshake not completed")
|
||||
}
|
||||
|
||||
obfsConn := buildEarlyClientObfsConn(raw, s.cfg, s.table)
|
||||
rc, err := crypto.NewRecordConn(obfsConn, s.cfg.AEAD, s.sessionC2S, s.sessionS2C)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("setup client session crypto failed: %w", err)
|
||||
}
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
func (s *EarlyClientState) Ready() bool {
|
||||
return s != nil && s.responseSet
|
||||
}
|
||||
|
||||
func NewHTTPMaskClientEarlyHandshake(cfg EarlyCodecConfig, table *sudokuobfs.Table, tableHint uint32, hasTableHint bool, userHash [kipHelloUserHashSize]byte, feats uint32) (*httpmaskobfs.ClientEarlyHandshake, error) {
|
||||
state, err := NewEarlyClientState(cfg, table, tableHint, hasTableHint, userHash, feats)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &httpmaskobfs.ClientEarlyHandshake{
|
||||
RequestPayload: state.RequestPayload,
|
||||
HandleResponse: state.ProcessResponse,
|
||||
Ready: state.Ready,
|
||||
WrapConn: state.WrapConn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ProcessEarlyClientPayload(cfg EarlyCodecConfig, tables []*sudokuobfs.Table, payload []byte, allowReplay ReplayAllowFunc) (*EarlyServerState, error) {
|
||||
if len(payload) == 0 {
|
||||
return nil, fmt.Errorf("empty early payload")
|
||||
}
|
||||
if len(tables) == 0 {
|
||||
return nil, fmt.Errorf("no tables configured")
|
||||
}
|
||||
|
||||
var firstErr error
|
||||
for _, table := range tables {
|
||||
state, err := processEarlyClientPayloadForTable(cfg, tables, table, payload, allowReplay)
|
||||
if err == nil {
|
||||
return state, nil
|
||||
}
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("early handshake probe failed")
|
||||
}
|
||||
return nil, firstErr
|
||||
}
|
||||
|
||||
func processEarlyClientPayloadForTable(cfg EarlyCodecConfig, tables []*sudokuobfs.Table, table *sudokuobfs.Table, payload []byte, allowReplay ReplayAllowFunc) (*EarlyServerState, error) {
|
||||
mem := newEarlyMemoryConn(payload)
|
||||
obfsConn := buildEarlyServerObfsConn(mem, cfg, table)
|
||||
pskC2S, pskS2C := derivePSKDirectionalBases(cfg.PSK)
|
||||
rc, err := crypto.NewRecordConn(obfsConn, cfg.AEAD, pskS2C, pskC2S)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msg, err := ReadKIPMessage(rc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.Type != KIPTypeClientHello {
|
||||
return nil, fmt.Errorf("unexpected handshake message: %d", msg.Type)
|
||||
}
|
||||
ch, err := DecodeKIPClientHelloPayload(msg.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if absInt64(time.Now().Unix()-ch.Timestamp.Unix()) > int64(earlyKIPHandshakeTTL.Seconds()) {
|
||||
return nil, fmt.Errorf("time skew/replay")
|
||||
}
|
||||
|
||||
userHash := hex.EncodeToString(ch.UserHash[:])
|
||||
if allowReplay != nil && !allowReplay(userHash, ch.Nonce, time.Now()) {
|
||||
return nil, fmt.Errorf("replay detected")
|
||||
}
|
||||
resolvedTable, err := ResolveClientHelloTable(table, tables, ch)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve table hint failed: %w", err)
|
||||
}
|
||||
|
||||
curve := ecdh.X25519()
|
||||
serverEphemeral, err := curve.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ecdh generate failed: %w", err)
|
||||
}
|
||||
shared, err := x25519SharedSecret(serverEphemeral, ch.ClientPub[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ecdh failed: %w", err)
|
||||
}
|
||||
sessionC2S, sessionS2C, err := deriveSessionDirectionalBases(cfg.PSK, shared, ch.Nonce)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("derive session keys failed: %w", err)
|
||||
}
|
||||
|
||||
var serverPub [kipHelloPubSize]byte
|
||||
copy(serverPub[:], serverEphemeral.PublicKey().Bytes())
|
||||
serverHello := &KIPServerHello{
|
||||
Nonce: ch.Nonce,
|
||||
ServerPub: serverPub,
|
||||
SelectedFeats: ch.Features & KIPFeatAll,
|
||||
}
|
||||
|
||||
respMem := newEarlyMemoryConn(nil)
|
||||
respObfs := buildEarlyServerObfsConn(respMem, cfg, resolvedTable)
|
||||
respConn, err := crypto.NewRecordConn(respObfs, cfg.AEAD, pskS2C, pskC2S)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("server early crypto setup failed: %w", err)
|
||||
}
|
||||
if err := WriteKIPMessage(respConn, KIPTypeServerHello, serverHello.EncodePayload()); err != nil {
|
||||
return nil, fmt.Errorf("write early server hello failed: %w", err)
|
||||
}
|
||||
|
||||
return &EarlyServerState{
|
||||
ResponsePayload: respMem.Written(),
|
||||
UserHash: userHash,
|
||||
cfg: cfg,
|
||||
table: resolvedTable,
|
||||
sessionC2S: sessionC2S,
|
||||
sessionS2C: sessionS2C,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *EarlyServerState) WrapConn(raw net.Conn) (net.Conn, error) {
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("nil server state")
|
||||
}
|
||||
obfsConn := buildEarlyServerObfsConn(raw, s.cfg, s.table)
|
||||
rc, err := crypto.NewRecordConn(obfsConn, s.cfg.AEAD, s.sessionS2C, s.sessionC2S)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("setup server session crypto failed: %w", err)
|
||||
}
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
func NewHTTPMaskServerEarlyHandshake(cfg EarlyCodecConfig, tables []*sudokuobfs.Table, allowReplay ReplayAllowFunc) *httpmaskobfs.TunnelServerEarlyHandshake {
|
||||
return &httpmaskobfs.TunnelServerEarlyHandshake{
|
||||
Prepare: func(payload []byte) (*httpmaskobfs.PreparedServerEarlyHandshake, error) {
|
||||
state, err := ProcessEarlyClientPayload(cfg, tables, payload, allowReplay)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &httpmaskobfs.PreparedServerEarlyHandshake{
|
||||
ResponsePayload: state.ResponsePayload,
|
||||
WrapConn: state.WrapConn,
|
||||
UserHash: state.UserHash,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
511
transport/sudoku/handshake.go
Normal file
511
transport/sudoku/handshake.go
Normal file
@@ -0,0 +1,511 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/transport/sudoku/crypto"
|
||||
"github.com/sagernet/sing-box/transport/sudoku/obfs/httpmask"
|
||||
"github.com/sagernet/sing-box/transport/sudoku/obfs/sudoku"
|
||||
)
|
||||
|
||||
type SessionType int
|
||||
|
||||
const (
|
||||
SessionTypeTCP SessionType = iota
|
||||
SessionTypeUoT
|
||||
SessionTypeMultiplex
|
||||
)
|
||||
|
||||
type ServerSession struct {
|
||||
Conn net.Conn
|
||||
Type SessionType
|
||||
Target string
|
||||
|
||||
// UserHash is a stable per-key identifier derived from the client hello payload.
|
||||
UserHash string
|
||||
}
|
||||
|
||||
type HandshakeMeta struct {
|
||||
UserHash string
|
||||
}
|
||||
|
||||
// SuspiciousError indicates a potential probing attempt or protocol violation.
|
||||
// When returned, Conn (if non-nil) should contain all bytes already consumed/buffered so the caller
|
||||
// can perform a best-effort fallback relay (e.g. to a local web server) without losing the request.
|
||||
type SuspiciousError struct {
|
||||
Err error
|
||||
Conn net.Conn
|
||||
}
|
||||
|
||||
func (e *SuspiciousError) Error() string {
|
||||
if e == nil || e.Err == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *SuspiciousError) Unwrap() error { return e.Err }
|
||||
|
||||
type recordedConn struct {
|
||||
net.Conn
|
||||
recorded []byte
|
||||
}
|
||||
|
||||
func (rc *recordedConn) GetBufferedAndRecorded() []byte { return rc.recorded }
|
||||
|
||||
type prefixedRecorderConn struct {
|
||||
net.Conn
|
||||
prefix []byte
|
||||
}
|
||||
|
||||
func (pc *prefixedRecorderConn) GetBufferedAndRecorded() []byte {
|
||||
var rest []byte
|
||||
if r, ok := pc.Conn.(interface{ GetBufferedAndRecorded() []byte }); ok {
|
||||
rest = r.GetBufferedAndRecorded()
|
||||
}
|
||||
out := make([]byte, 0, len(pc.prefix)+len(rest))
|
||||
out = append(out, pc.prefix...)
|
||||
out = append(out, rest...)
|
||||
return out
|
||||
}
|
||||
|
||||
// bufferedRecorderConn wraps a net.Conn and a shared bufio.Reader so we can expose buffered bytes.
|
||||
// This is used for legacy HTTP mask parsing errors so callers can fall back to a real HTTP server.
|
||||
type bufferedRecorderConn struct {
|
||||
net.Conn
|
||||
r *bufio.Reader
|
||||
recorder *bytes.Buffer
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (bc *bufferedRecorderConn) Read(p []byte) (n int, err error) {
|
||||
n, err = bc.r.Read(p)
|
||||
if n > 0 && bc.recorder != nil {
|
||||
bc.mu.Lock()
|
||||
bc.recorder.Write(p[:n])
|
||||
bc.mu.Unlock()
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (bc *bufferedRecorderConn) GetBufferedAndRecorded() []byte {
|
||||
if bc == nil {
|
||||
return nil
|
||||
}
|
||||
bc.mu.Lock()
|
||||
defer bc.mu.Unlock()
|
||||
|
||||
var recorded []byte
|
||||
if bc.recorder != nil {
|
||||
recorded = bc.recorder.Bytes()
|
||||
}
|
||||
buffered := 0
|
||||
if bc.r != nil {
|
||||
buffered = bc.r.Buffered()
|
||||
}
|
||||
if buffered <= 0 {
|
||||
return recorded
|
||||
}
|
||||
peeked, _ := bc.r.Peek(buffered)
|
||||
full := make([]byte, len(recorded)+len(peeked))
|
||||
copy(full, recorded)
|
||||
copy(full[len(recorded):], peeked)
|
||||
return full
|
||||
}
|
||||
|
||||
type preBufferedConn struct {
|
||||
net.Conn
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (p *preBufferedConn) Read(b []byte) (int, error) {
|
||||
if len(p.buf) > 0 {
|
||||
n := copy(b, p.buf)
|
||||
p.buf = p.buf[n:]
|
||||
return n, nil
|
||||
}
|
||||
if p.Conn == nil {
|
||||
return 0, io.EOF
|
||||
}
|
||||
return p.Conn.Read(b)
|
||||
}
|
||||
|
||||
func (p *preBufferedConn) CloseWrite() error {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
if cw, ok := p.Conn.(interface{ CloseWrite() error }); ok {
|
||||
return cw.CloseWrite()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *preBufferedConn) CloseRead() error {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
if cr, ok := p.Conn.(interface{ CloseRead() error }); ok {
|
||||
return cr.CloseRead()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type directionalConn struct {
|
||||
net.Conn
|
||||
reader io.Reader
|
||||
writer io.Writer
|
||||
closers []func() error
|
||||
}
|
||||
|
||||
func newDirectionalConn(base net.Conn, reader io.Reader, writer io.Writer, closers ...func() error) net.Conn {
|
||||
return &directionalConn{
|
||||
Conn: base,
|
||||
reader: reader,
|
||||
writer: writer,
|
||||
closers: closers,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *directionalConn) Read(p []byte) (int, error) {
|
||||
return c.reader.Read(p)
|
||||
}
|
||||
|
||||
func (c *directionalConn) Write(p []byte) (int, error) {
|
||||
return c.writer.Write(p)
|
||||
}
|
||||
|
||||
func (c *directionalConn) ReplaceWriter(writer io.Writer, closers ...func() error) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.writer = writer
|
||||
c.closers = closers
|
||||
}
|
||||
|
||||
func (c *directionalConn) Close() error {
|
||||
var firstErr error
|
||||
for _, fn := range c.closers {
|
||||
if fn == nil {
|
||||
continue
|
||||
}
|
||||
if err := fn(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
if err := c.Conn.Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (c *directionalConn) CloseWrite() error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if cw, ok := c.Conn.(interface{ CloseWrite() error }); ok {
|
||||
return cw.CloseWrite()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *directionalConn) CloseRead() error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if cr, ok := c.Conn.(interface{ CloseRead() error }); ok {
|
||||
return cr.CloseRead()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func absInt64(v int64) int64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func oppositeDirectionTable(table *sudoku.Table) *sudoku.Table {
|
||||
if table == nil {
|
||||
return nil
|
||||
}
|
||||
if other := table.OppositeDirection(); other != nil {
|
||||
return other
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
func newClientDownlinkReader(raw net.Conn, table *sudoku.Table, paddingMin, paddingMax int, pureDownlink bool) io.Reader {
|
||||
downlinkTable := oppositeDirectionTable(table)
|
||||
if pureDownlink {
|
||||
if downlinkTable == table {
|
||||
return nil
|
||||
}
|
||||
return sudoku.NewConn(raw, downlinkTable, paddingMin, paddingMax, false)
|
||||
}
|
||||
return sudoku.NewPackedConn(raw, downlinkTable, paddingMin, paddingMax)
|
||||
}
|
||||
|
||||
func newServerDownlinkWriter(raw net.Conn, table *sudoku.Table, paddingMin, paddingMax int, pureDownlink bool) (io.Writer, []func() error) {
|
||||
downlinkTable := oppositeDirectionTable(table)
|
||||
if pureDownlink {
|
||||
if downlinkTable == table {
|
||||
return nil, nil
|
||||
}
|
||||
return sudoku.NewConn(raw, downlinkTable, paddingMin, paddingMax, false), nil
|
||||
}
|
||||
packed := sudoku.NewPackedConn(raw, downlinkTable, paddingMin, paddingMax)
|
||||
return packed, []func() error{packed.Flush}
|
||||
}
|
||||
|
||||
func buildClientObfsConn(raw net.Conn, cfg *ProtocolConfig, table *sudoku.Table) net.Conn {
|
||||
baseSudoku := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false)
|
||||
downlinkReader := newClientDownlinkReader(raw, table, cfg.PaddingMin, cfg.PaddingMax, cfg.EnablePureDownlink)
|
||||
if downlinkReader == nil {
|
||||
return baseSudoku
|
||||
}
|
||||
return newDirectionalConn(raw, downlinkReader, baseSudoku)
|
||||
}
|
||||
|
||||
func buildServerObfsConn(raw net.Conn, cfg *ProtocolConfig, table *sudoku.Table, record bool) (*sudoku.Conn, net.Conn) {
|
||||
uplinkSudoku := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, record)
|
||||
downlinkWriter, closers := newServerDownlinkWriter(raw, table, cfg.PaddingMin, cfg.PaddingMax, cfg.EnablePureDownlink)
|
||||
if downlinkWriter == nil {
|
||||
return uplinkSudoku, uplinkSudoku
|
||||
}
|
||||
return uplinkSudoku, newDirectionalConn(raw, uplinkSudoku, downlinkWriter, closers...)
|
||||
}
|
||||
|
||||
func isLegacyHTTPMaskMode(mode string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "", "legacy":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ClientHandshake performs the client-side Sudoku handshake (no target request).
|
||||
func ClientHandshake(rawConn net.Conn, cfg *ProtocolConfig) (net.Conn, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("config is required")
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
if !cfg.DisableHTTPMask && isLegacyHTTPMaskMode(cfg.HTTPMaskMode) {
|
||||
if err := httpmask.WriteRandomRequestHeaderWithPathRoot(rawConn, cfg.ServerAddress, cfg.HTTPMaskPathRoot); err != nil {
|
||||
return nil, fmt.Errorf("write http mask failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
choice, err := pickClientTable(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seed := ClientAEADSeed(cfg.Key)
|
||||
obfsConn := buildClientObfsConn(rawConn, cfg, choice.Table)
|
||||
pskC2S, pskS2C := derivePSKDirectionalBases(seed)
|
||||
rc, err := crypto.NewRecordConn(obfsConn, cfg.AEADMethod, pskC2S, pskS2C)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("setup crypto failed: %w", err)
|
||||
}
|
||||
|
||||
if _, err := kipHandshakeClient(rc, seed, kipUserHashFromKey(cfg.Key), KIPFeatAll, choice.Hint, choice.HasHint); err != nil {
|
||||
_ = rc.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
func readFirstSessionMessage(conn net.Conn) (*KIPMessage, error) {
|
||||
for {
|
||||
msg, err := ReadKIPMessage(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.Type == KIPTypeKeepAlive {
|
||||
continue
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
}
|
||||
|
||||
func maybeConsumeLegacyHTTPMask(rawConn net.Conn, r *bufio.Reader, cfg *ProtocolConfig) ([]byte, *SuspiciousError) {
|
||||
if rawConn == nil || r == nil || cfg == nil || cfg.DisableHTTPMask || !isLegacyHTTPMaskMode(cfg.HTTPMaskMode) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
peekBytes, _ := r.Peek(4) // ignore error; subsequent read will handle it
|
||||
if !httpmask.LooksLikeHTTPRequestStart(peekBytes) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
consumed, err := httpmask.ConsumeHeader(r)
|
||||
if err == nil {
|
||||
return consumed, nil
|
||||
}
|
||||
|
||||
recorder := new(bytes.Buffer)
|
||||
if len(consumed) > 0 {
|
||||
recorder.Write(consumed)
|
||||
}
|
||||
badConn := &bufferedRecorderConn{Conn: rawConn, r: r, recorder: recorder}
|
||||
return consumed, &SuspiciousError{Err: fmt.Errorf("invalid http header: %w", err), Conn: badConn}
|
||||
}
|
||||
|
||||
// ServerHandshake performs the server-side KIP handshake.
|
||||
func ServerHandshake(rawConn net.Conn, cfg *ProtocolConfig) (net.Conn, *HandshakeMeta, error) {
|
||||
if rawConn == nil {
|
||||
return nil, nil, fmt.Errorf("nil conn")
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, nil, fmt.Errorf("config is required")
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
if userHash, ok := httpmask.EarlyHandshakeUserHash(rawConn); ok {
|
||||
return rawConn, &HandshakeMeta{UserHash: userHash}, nil
|
||||
}
|
||||
|
||||
handshakeTimeout := time.Duration(cfg.HandshakeTimeoutSeconds) * time.Second
|
||||
if handshakeTimeout <= 0 {
|
||||
handshakeTimeout = 5 * time.Second
|
||||
}
|
||||
|
||||
bufReader := bufio.NewReader(rawConn)
|
||||
_ = rawConn.SetReadDeadline(time.Now().Add(handshakeTimeout))
|
||||
defer func() { _ = rawConn.SetReadDeadline(time.Time{}) }()
|
||||
|
||||
httpHeaderData, susp := maybeConsumeLegacyHTTPMask(rawConn, bufReader, cfg)
|
||||
if susp != nil {
|
||||
return nil, nil, susp
|
||||
}
|
||||
|
||||
selectedTable, preRead, err := selectTableByProbe(bufReader, cfg, cfg.tableCandidates())
|
||||
if err != nil {
|
||||
combined := make([]byte, 0, len(httpHeaderData)+len(preRead))
|
||||
combined = append(combined, httpHeaderData...)
|
||||
combined = append(combined, preRead...)
|
||||
return nil, nil, &SuspiciousError{Err: err, Conn: &recordedConn{Conn: rawConn, recorded: combined}}
|
||||
}
|
||||
|
||||
baseConn := &preBufferedConn{Conn: rawConn, buf: preRead}
|
||||
sConn, obfsConn := buildServerObfsConn(baseConn, cfg, selectedTable, true)
|
||||
|
||||
seed := ServerAEADSeed(cfg.Key)
|
||||
pskC2S, pskS2C := derivePSKDirectionalBases(seed)
|
||||
// Server side: recv is client->server, send is server->client.
|
||||
rc, err := crypto.NewRecordConn(obfsConn, cfg.AEADMethod, pskS2C, pskC2S)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("setup crypto failed: %w", err)
|
||||
}
|
||||
|
||||
msg, err := ReadKIPMessage(rc)
|
||||
if err != nil {
|
||||
return nil, nil, &SuspiciousError{Err: fmt.Errorf("handshake read failed: %w", err), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}}
|
||||
}
|
||||
if msg.Type != KIPTypeClientHello {
|
||||
return nil, nil, &SuspiciousError{Err: fmt.Errorf("unexpected handshake message: %d", msg.Type), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}}
|
||||
}
|
||||
ch, err := DecodeKIPClientHelloPayload(msg.Payload)
|
||||
if err != nil {
|
||||
return nil, nil, &SuspiciousError{Err: fmt.Errorf("decode client hello failed: %w", err), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}}
|
||||
}
|
||||
if absInt64(time.Now().Unix()-ch.Timestamp.Unix()) > int64(kipHandshakeSkew.Seconds()) {
|
||||
return nil, nil, &SuspiciousError{Err: fmt.Errorf("time skew/replay"), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}}
|
||||
}
|
||||
|
||||
userHashHex := hex.EncodeToString(ch.UserHash[:])
|
||||
if !globalHandshakeReplay.allow(userHashHex, ch.Nonce, time.Now()) {
|
||||
return nil, nil, &SuspiciousError{Err: fmt.Errorf("replay"), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}}
|
||||
}
|
||||
resolvedTable, err := ResolveClientHelloTable(selectedTable, cfg.tableCandidates(), ch)
|
||||
if err != nil {
|
||||
return nil, nil, &SuspiciousError{Err: fmt.Errorf("resolve table hint failed: %w", err), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}}
|
||||
}
|
||||
if resolvedTable != selectedTable {
|
||||
downlinkWriter, closers := newServerDownlinkWriter(baseConn, resolvedTable, cfg.PaddingMin, cfg.PaddingMax, cfg.EnablePureDownlink)
|
||||
switchable, ok := obfsConn.(*directionalConn)
|
||||
if !ok {
|
||||
return nil, nil, &SuspiciousError{Err: fmt.Errorf("switch downlink writer failed"), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}}
|
||||
}
|
||||
switchable.ReplaceWriter(downlinkWriter, closers...)
|
||||
}
|
||||
|
||||
curve := ecdh.X25519()
|
||||
serverEphemeral, err := curve.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, &SuspiciousError{Err: fmt.Errorf("ecdh generate failed: %w", err), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}}
|
||||
}
|
||||
shared, err := x25519SharedSecret(serverEphemeral, ch.ClientPub[:])
|
||||
if err != nil {
|
||||
return nil, nil, &SuspiciousError{Err: fmt.Errorf("ecdh failed: %w", err), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}}
|
||||
}
|
||||
sessC2S, sessS2C, err := deriveSessionDirectionalBases(seed, shared, ch.Nonce)
|
||||
if err != nil {
|
||||
return nil, nil, &SuspiciousError{Err: fmt.Errorf("derive session keys failed: %w", err), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}}
|
||||
}
|
||||
|
||||
var serverPub [kipHelloPubSize]byte
|
||||
copy(serverPub[:], serverEphemeral.PublicKey().Bytes())
|
||||
sh := &KIPServerHello{
|
||||
Nonce: ch.Nonce,
|
||||
ServerPub: serverPub,
|
||||
SelectedFeats: ch.Features & KIPFeatAll,
|
||||
}
|
||||
if err := WriteKIPMessage(rc, KIPTypeServerHello, sh.EncodePayload()); err != nil {
|
||||
return nil, nil, &SuspiciousError{Err: fmt.Errorf("write server hello failed: %w", err), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}}
|
||||
}
|
||||
if err := rc.Rekey(sessS2C, sessC2S); err != nil {
|
||||
return nil, nil, &SuspiciousError{Err: fmt.Errorf("rekey failed: %w", err), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}}
|
||||
}
|
||||
|
||||
sConn.StopRecording()
|
||||
return rc, &HandshakeMeta{UserHash: userHashHex}, nil
|
||||
}
|
||||
|
||||
// ReadServerSession consumes the first post-handshake KIP control message and returns the session intent.
|
||||
func ReadServerSession(conn net.Conn, meta *HandshakeMeta) (*ServerSession, error) {
|
||||
if conn == nil {
|
||||
return nil, fmt.Errorf("nil conn")
|
||||
}
|
||||
userHash := ""
|
||||
if meta != nil {
|
||||
userHash = meta.UserHash
|
||||
}
|
||||
|
||||
first, err := readFirstSessionMessage(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch first.Type {
|
||||
case KIPTypeStartUoT:
|
||||
return &ServerSession{Conn: conn, Type: SessionTypeUoT, UserHash: userHash}, nil
|
||||
case KIPTypeStartMux:
|
||||
return &ServerSession{Conn: conn, Type: SessionTypeMultiplex, UserHash: userHash}, nil
|
||||
case KIPTypeOpenTCP:
|
||||
target, err := DecodeAddress(bytes.NewReader(first.Payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode target address failed: %w", err)
|
||||
}
|
||||
return &ServerSession{Conn: conn, Type: SessionTypeTCP, Target: target, UserHash: userHash}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown kip message: %d", first.Type)
|
||||
}
|
||||
}
|
||||
67
transport/sudoku/handshake_kip.go
Normal file
67
transport/sudoku/handshake_kip.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/transport/sudoku/crypto"
|
||||
)
|
||||
|
||||
const kipHandshakeSkew = 60 * time.Second
|
||||
|
||||
func kipHandshakeClient(rc *crypto.RecordConn, seed string, userHash [kipHelloUserHashSize]byte, feats uint32, tableHint uint32, hasTableHint bool) (uint32, error) {
|
||||
if rc == nil {
|
||||
return 0, fmt.Errorf("nil conn")
|
||||
}
|
||||
|
||||
curve := ecdh.X25519()
|
||||
ephemeral, err := curve.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ecdh generate failed: %w", err)
|
||||
}
|
||||
|
||||
var nonce [kipHelloNonceSize]byte
|
||||
if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil {
|
||||
return 0, fmt.Errorf("nonce generate failed: %w", err)
|
||||
}
|
||||
|
||||
var clientPub [kipHelloPubSize]byte
|
||||
copy(clientPub[:], ephemeral.PublicKey().Bytes())
|
||||
|
||||
ch := newKIPClientHello(userHash, nonce, clientPub, feats, tableHint, hasTableHint)
|
||||
if err := WriteKIPMessage(rc, KIPTypeClientHello, ch.EncodePayload()); err != nil {
|
||||
return 0, fmt.Errorf("write client hello failed: %w", err)
|
||||
}
|
||||
|
||||
msg, err := ReadKIPMessage(rc)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read server hello failed: %w", err)
|
||||
}
|
||||
if msg.Type != KIPTypeServerHello {
|
||||
return 0, fmt.Errorf("unexpected handshake message: %d", msg.Type)
|
||||
}
|
||||
sh, err := DecodeKIPServerHelloPayload(msg.Payload)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("decode server hello failed: %w", err)
|
||||
}
|
||||
if sh.Nonce != nonce {
|
||||
return 0, fmt.Errorf("handshake nonce mismatch")
|
||||
}
|
||||
|
||||
shared, err := x25519SharedSecret(ephemeral, sh.ServerPub[:])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ecdh failed: %w", err)
|
||||
}
|
||||
sessC2S, sessS2C, err := deriveSessionDirectionalBases(seed, shared, nonce)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("derive session keys failed: %w", err)
|
||||
}
|
||||
if err := rc.Rekey(sessC2S, sessS2C); err != nil {
|
||||
return 0, fmt.Errorf("rekey failed: %w", err)
|
||||
}
|
||||
|
||||
return sh.SelectedFeats, nil
|
||||
}
|
||||
155
transport/sudoku/httpmask_tunnel.go
Normal file
155
transport/sudoku/httpmask_tunnel.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/sing-box/transport/sudoku/obfs/httpmask"
|
||||
)
|
||||
|
||||
type HTTPMaskTunnelServer struct {
|
||||
cfg *ProtocolConfig
|
||||
ts *httpmask.TunnelServer
|
||||
}
|
||||
|
||||
func newHTTPMaskEarlyCodecConfig(cfg *ProtocolConfig, psk string) EarlyCodecConfig {
|
||||
return EarlyCodecConfig{
|
||||
PSK: psk,
|
||||
AEAD: cfg.AEADMethod,
|
||||
EnablePureDownlink: cfg.EnablePureDownlink,
|
||||
PaddingMin: cfg.PaddingMin,
|
||||
PaddingMax: cfg.PaddingMax,
|
||||
}
|
||||
}
|
||||
|
||||
func newClientHTTPMaskEarlyHandshake(cfg *ProtocolConfig) (*httpmask.ClientEarlyHandshake, error) {
|
||||
choice, err := pickClientTable(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewHTTPMaskClientEarlyHandshake(
|
||||
newHTTPMaskEarlyCodecConfig(cfg, ClientAEADSeed(cfg.Key)),
|
||||
choice.Table,
|
||||
choice.Hint,
|
||||
choice.HasHint,
|
||||
kipUserHashFromKey(cfg.Key),
|
||||
KIPFeatAll,
|
||||
)
|
||||
}
|
||||
|
||||
func NewHTTPMaskTunnelServer(cfg *ProtocolConfig) *HTTPMaskTunnelServer {
|
||||
return newHTTPMaskTunnelServer(cfg, false)
|
||||
}
|
||||
|
||||
func NewHTTPMaskTunnelServerWithFallback(cfg *ProtocolConfig) *HTTPMaskTunnelServer {
|
||||
return newHTTPMaskTunnelServer(cfg, true)
|
||||
}
|
||||
|
||||
func newHTTPMaskTunnelServer(cfg *ProtocolConfig, passThroughOnReject bool) *HTTPMaskTunnelServer {
|
||||
if cfg == nil {
|
||||
return &HTTPMaskTunnelServer{}
|
||||
}
|
||||
|
||||
var ts *httpmask.TunnelServer
|
||||
if !cfg.DisableHTTPMask {
|
||||
switch strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) {
|
||||
case "stream", "poll", "auto", "ws":
|
||||
ts = httpmask.NewTunnelServer(httpmask.TunnelServerOptions{
|
||||
Mode: cfg.HTTPMaskMode,
|
||||
PathRoot: cfg.HTTPMaskPathRoot,
|
||||
AuthKey: ServerAEADSeed(cfg.Key),
|
||||
EarlyHandshake: NewHTTPMaskServerEarlyHandshake(
|
||||
newHTTPMaskEarlyCodecConfig(cfg, ServerAEADSeed(cfg.Key)),
|
||||
cfg.tableCandidates(),
|
||||
globalHandshakeReplay.allow,
|
||||
),
|
||||
// When upstream fallback is enabled, preserve rejected HTTP requests for the caller.
|
||||
PassThroughOnReject: passThroughOnReject,
|
||||
})
|
||||
}
|
||||
}
|
||||
return &HTTPMaskTunnelServer{cfg: cfg, ts: ts}
|
||||
}
|
||||
|
||||
// WrapConn inspects an accepted TCP connection and upgrades it to an HTTP tunnel stream when needed.
|
||||
//
|
||||
// Returns:
|
||||
// - done=true: this TCP connection has been fully handled (e.g., stream/poll control request), caller should return
|
||||
// - done=false: handshakeConn+cfg are ready for ServerHandshake
|
||||
func (s *HTTPMaskTunnelServer) WrapConn(rawConn net.Conn) (handshakeConn net.Conn, cfg *ProtocolConfig, done bool, err error) {
|
||||
if rawConn == nil {
|
||||
return nil, nil, true, fmt.Errorf("nil conn")
|
||||
}
|
||||
if s == nil {
|
||||
return rawConn, nil, false, nil
|
||||
}
|
||||
if s.ts == nil {
|
||||
return rawConn, s.cfg, false, nil
|
||||
}
|
||||
|
||||
res, c, err := s.ts.HandleConn(rawConn)
|
||||
if err != nil {
|
||||
return nil, nil, true, err
|
||||
}
|
||||
|
||||
switch res {
|
||||
case httpmask.HandleDone:
|
||||
return nil, nil, true, nil
|
||||
case httpmask.HandlePassThrough:
|
||||
return c, s.cfg, false, nil
|
||||
case httpmask.HandleStartTunnel:
|
||||
inner := *s.cfg
|
||||
inner.DisableHTTPMask = true
|
||||
// HTTPMask tunnel modes (stream/poll/auto/ws) add extra round trips before the first
|
||||
// handshake bytes can reach ServerHandshake, especially under high concurrency.
|
||||
// Bump the handshake timeout for tunneled conns to avoid flaky timeouts while keeping
|
||||
// the default strict for raw TCP handshakes.
|
||||
const minTunneledHandshakeTimeoutSeconds = 15
|
||||
if inner.HandshakeTimeoutSeconds <= 0 || inner.HandshakeTimeoutSeconds < minTunneledHandshakeTimeoutSeconds {
|
||||
inner.HandshakeTimeoutSeconds = minTunneledHandshakeTimeoutSeconds
|
||||
}
|
||||
return c, &inner, false, nil
|
||||
default:
|
||||
return nil, nil, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
type TunnelDialer func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
|
||||
// DialHTTPMaskTunnel dials a CDN-capable HTTP tunnel (stream/poll/auto/ws) and returns a stream carrying raw Sudoku bytes.
|
||||
func DialHTTPMaskTunnel(ctx context.Context, serverAddress string, cfg *ProtocolConfig, dial TunnelDialer, upgrade func(net.Conn) (net.Conn, error)) (net.Conn, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("config is required")
|
||||
}
|
||||
if cfg.DisableHTTPMask {
|
||||
return nil, fmt.Errorf("http mask is disabled")
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) {
|
||||
case "stream", "poll", "auto", "ws":
|
||||
default:
|
||||
return nil, fmt.Errorf("http-mask-mode=%q does not use http tunnel", cfg.HTTPMaskMode)
|
||||
}
|
||||
var (
|
||||
earlyHandshake *httpmask.ClientEarlyHandshake
|
||||
err error
|
||||
)
|
||||
if upgrade != nil {
|
||||
earlyHandshake, err = newClientHTTPMaskEarlyHandshake(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return httpmask.DialTunnel(ctx, serverAddress, httpmask.TunnelDialOptions{
|
||||
Mode: cfg.HTTPMaskMode,
|
||||
HostOverride: cfg.HTTPMaskHost,
|
||||
PathRoot: cfg.HTTPMaskPathRoot,
|
||||
AuthKey: ClientAEADSeed(cfg.Key),
|
||||
EarlyHandshake: earlyHandshake,
|
||||
Upgrade: upgrade,
|
||||
Multiplex: cfg.HTTPMaskMultiplex,
|
||||
DialContext: dial,
|
||||
})
|
||||
}
|
||||
101
transport/sudoku/init.go
Normal file
101
transport/sudoku/init.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"filippo.io/edwards25519"
|
||||
"github.com/sagernet/sing-box/transport/sudoku/crypto"
|
||||
"github.com/sagernet/sing-box/transport/sudoku/obfs/sudoku"
|
||||
)
|
||||
|
||||
func NewTable(key string, tableType string) *sudoku.Table {
|
||||
table, err := NewTableWithCustom(key, tableType, "")
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("[Sudoku] failed to init tables: %v", err))
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
func NewTableWithCustom(key string, tableType string, customTable string) (*sudoku.Table, error) {
|
||||
table, err := sudoku.NewTableWithCustom(key, tableType, customTable)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return table, nil
|
||||
}
|
||||
|
||||
// ClientAEADSeed returns a canonical "seed" that is stable between client private key material and server public key.
|
||||
func ClientAEADSeed(key string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
b, err := hex.DecodeString(key)
|
||||
if err != nil {
|
||||
return key
|
||||
}
|
||||
|
||||
// Client-side key material can be:
|
||||
// - public key: 32 bytes hex compressed point
|
||||
// - split private key: 64 bytes hex (r||k)
|
||||
// - master private scalar: 32 bytes hex (x)
|
||||
// - PSK string: non-hex
|
||||
//
|
||||
// 32-byte hex is ambiguous: it can be either a compressed public key or a
|
||||
// master private scalar. Official Sudoku runtime accepts public keys directly,
|
||||
// so when the bytes already decode as a point, preserve that point verbatim.
|
||||
if len(b) == 32 {
|
||||
if p, err := new(edwards25519.Point).SetBytes(b); err == nil {
|
||||
return hex.EncodeToString(p.Bytes())
|
||||
}
|
||||
}
|
||||
if len(b) != 64 && len(b) != 32 {
|
||||
return key
|
||||
}
|
||||
if recovered, err := crypto.RecoverPublicKey(key); err == nil {
|
||||
return crypto.EncodePoint(recovered)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// ServerAEADSeed returns a canonical seed for server-side configuration.
|
||||
//
|
||||
// When key is a public key (32-byte compressed point, hex), it returns the canonical point encoding.
|
||||
// When key is private key material (split/master scalar), it derives and returns the public key.
|
||||
func ServerAEADSeed(key string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
b, err := hex.DecodeString(key)
|
||||
if err != nil {
|
||||
return key
|
||||
}
|
||||
|
||||
// Prefer interpreting 32-byte hex as a public key point, to avoid accidental scalar parsing.
|
||||
if len(b) == 32 {
|
||||
if p, err := new(edwards25519.Point).SetBytes(b); err == nil {
|
||||
return hex.EncodeToString(p.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to client-side rules for private key materials / other formats.
|
||||
return ClientAEADSeed(key)
|
||||
}
|
||||
|
||||
// GenKeyPair generates a client "available private key" and the corresponding server public key.
|
||||
func GenKeyPair() (privateKey, publicKey string, err error) {
|
||||
pair, err := crypto.GenerateMasterKey()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
availablePrivateKey, err := crypto.SplitPrivateKey(pair.Private)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return availablePrivateKey, crypto.EncodePoint(pair.Public), nil
|
||||
}
|
||||
259
transport/sudoku/kip.go
Normal file
259
transport/sudoku/kip.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sudokuobfs "github.com/sagernet/sing-box/transport/sudoku/obfs/sudoku"
|
||||
)
|
||||
|
||||
const (
|
||||
kipMagic = "kip"
|
||||
|
||||
KIPTypeClientHello byte = 0x01
|
||||
KIPTypeServerHello byte = 0x02
|
||||
|
||||
KIPTypeOpenTCP byte = 0x10
|
||||
KIPTypeStartMux byte = 0x11
|
||||
KIPTypeStartUoT byte = 0x12
|
||||
KIPTypeKeepAlive byte = 0x14
|
||||
)
|
||||
|
||||
// KIP feature bits are advisory capability flags negotiated during the handshake.
|
||||
// They represent control-plane message families.
|
||||
const (
|
||||
KIPFeatOpenTCP uint32 = 1 << 0
|
||||
KIPFeatMux uint32 = 1 << 1
|
||||
KIPFeatUoT uint32 = 1 << 2
|
||||
KIPFeatKeepAlive uint32 = 1 << 4
|
||||
|
||||
KIPFeatAll = KIPFeatOpenTCP | KIPFeatMux | KIPFeatUoT | KIPFeatKeepAlive
|
||||
)
|
||||
|
||||
const (
|
||||
kipHelloUserHashSize = 8
|
||||
kipHelloNonceSize = 16
|
||||
kipHelloPubSize = 32
|
||||
kipMaxPayload = 64 * 1024
|
||||
)
|
||||
|
||||
const kipClientHelloTableHintSize = 4
|
||||
|
||||
var errKIP = errors.New("kip protocol error")
|
||||
|
||||
type KIPMessage struct {
|
||||
Type byte
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
func WriteKIPMessage(w io.Writer, typ byte, payload []byte) error {
|
||||
if w == nil {
|
||||
return fmt.Errorf("%w: nil writer", errKIP)
|
||||
}
|
||||
if len(payload) > kipMaxPayload {
|
||||
return fmt.Errorf("%w: payload too large: %d", errKIP, len(payload))
|
||||
}
|
||||
|
||||
var hdr [3 + 1 + 2]byte
|
||||
copy(hdr[:3], []byte(kipMagic))
|
||||
hdr[3] = typ
|
||||
binary.BigEndian.PutUint16(hdr[4:], uint16(len(payload)))
|
||||
|
||||
return writeAllChunks(w, hdr[:], payload)
|
||||
}
|
||||
|
||||
func ReadKIPMessage(r io.Reader) (*KIPMessage, error) {
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("%w: nil reader", errKIP)
|
||||
}
|
||||
var hdr [3 + 1 + 2]byte
|
||||
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if string(hdr[:3]) != kipMagic {
|
||||
return nil, fmt.Errorf("%w: bad magic", errKIP)
|
||||
}
|
||||
typ := hdr[3]
|
||||
n := int(binary.BigEndian.Uint16(hdr[4:]))
|
||||
if n < 0 || n > kipMaxPayload {
|
||||
return nil, fmt.Errorf("%w: invalid payload length: %d", errKIP, n)
|
||||
}
|
||||
var payload []byte
|
||||
if n > 0 {
|
||||
payload = make([]byte, n)
|
||||
if _, err := io.ReadFull(r, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &KIPMessage{Type: typ, Payload: payload}, nil
|
||||
}
|
||||
|
||||
type KIPClientHello struct {
|
||||
Timestamp time.Time
|
||||
UserHash [kipHelloUserHashSize]byte
|
||||
Nonce [kipHelloNonceSize]byte
|
||||
ClientPub [kipHelloPubSize]byte
|
||||
Features uint32
|
||||
TableHint uint32
|
||||
HasTableHint bool
|
||||
}
|
||||
|
||||
type KIPServerHello struct {
|
||||
Nonce [kipHelloNonceSize]byte
|
||||
ServerPub [kipHelloPubSize]byte
|
||||
SelectedFeats uint32
|
||||
}
|
||||
|
||||
func newKIPClientHello(userHash [kipHelloUserHashSize]byte, nonce [kipHelloNonceSize]byte, clientPub [kipHelloPubSize]byte, feats uint32, tableHint uint32, hasTableHint bool) *KIPClientHello {
|
||||
return &KIPClientHello{
|
||||
Timestamp: time.Now(),
|
||||
UserHash: userHash,
|
||||
Nonce: nonce,
|
||||
ClientPub: clientPub,
|
||||
Features: feats,
|
||||
TableHint: tableHint,
|
||||
HasTableHint: hasTableHint,
|
||||
}
|
||||
}
|
||||
|
||||
func kipUserHashFromKey(psk string) [kipHelloUserHashSize]byte {
|
||||
var out [kipHelloUserHashSize]byte
|
||||
psk = strings.TrimSpace(psk)
|
||||
if psk == "" {
|
||||
return out
|
||||
}
|
||||
|
||||
// Align with upstream: when the client carries private key material (or even just a public key),
|
||||
// prefer hashing the raw hex bytes so different split/master keys can be distinguished.
|
||||
if keyBytes, err := hex.DecodeString(psk); err == nil && len(keyBytes) > 0 {
|
||||
sum := sha256.Sum256(keyBytes)
|
||||
copy(out[:], sum[:kipHelloUserHashSize])
|
||||
return out
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(psk))
|
||||
copy(out[:], sum[:kipHelloUserHashSize])
|
||||
return out
|
||||
}
|
||||
|
||||
func KIPUserHashHexFromKey(psk string) string {
|
||||
uh := kipUserHashFromKey(psk)
|
||||
return hex.EncodeToString(uh[:])
|
||||
}
|
||||
|
||||
func (m *KIPClientHello) EncodePayload() []byte {
|
||||
var b bytes.Buffer
|
||||
var tmp [8]byte
|
||||
binary.BigEndian.PutUint64(tmp[:], uint64(m.Timestamp.Unix()))
|
||||
b.Write(tmp[:])
|
||||
b.Write(m.UserHash[:])
|
||||
b.Write(m.Nonce[:])
|
||||
b.Write(m.ClientPub[:])
|
||||
var f [4]byte
|
||||
binary.BigEndian.PutUint32(f[:], m.Features)
|
||||
b.Write(f[:])
|
||||
if m.HasTableHint {
|
||||
var hint [kipClientHelloTableHintSize]byte
|
||||
binary.BigEndian.PutUint32(hint[:], m.TableHint)
|
||||
b.Write(hint[:])
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func DecodeKIPClientHelloPayload(payload []byte) (*KIPClientHello, error) {
|
||||
const minLen = 8 + kipHelloUserHashSize + kipHelloNonceSize + kipHelloPubSize + 4
|
||||
if len(payload) < minLen {
|
||||
return nil, fmt.Errorf("%w: client hello too short", errKIP)
|
||||
}
|
||||
var h KIPClientHello
|
||||
ts := int64(binary.BigEndian.Uint64(payload[:8]))
|
||||
h.Timestamp = time.Unix(ts, 0)
|
||||
off := 8
|
||||
copy(h.UserHash[:], payload[off:off+kipHelloUserHashSize])
|
||||
off += kipHelloUserHashSize
|
||||
copy(h.Nonce[:], payload[off:off+kipHelloNonceSize])
|
||||
off += kipHelloNonceSize
|
||||
copy(h.ClientPub[:], payload[off:off+kipHelloPubSize])
|
||||
off += kipHelloPubSize
|
||||
h.Features = binary.BigEndian.Uint32(payload[off : off+4])
|
||||
off += 4
|
||||
if len(payload) >= off+kipClientHelloTableHintSize {
|
||||
h.TableHint = binary.BigEndian.Uint32(payload[off : off+kipClientHelloTableHintSize])
|
||||
h.HasTableHint = true
|
||||
}
|
||||
return &h, nil
|
||||
}
|
||||
|
||||
func ResolveClientHelloTable(selected *sudokuobfs.Table, candidates []*sudokuobfs.Table, hello *KIPClientHello) (*sudokuobfs.Table, error) {
|
||||
if selected == nil {
|
||||
return nil, fmt.Errorf("nil selected table")
|
||||
}
|
||||
if hello == nil || !hello.HasTableHint {
|
||||
return selected, nil
|
||||
}
|
||||
if selected.Hint() == hello.TableHint {
|
||||
return selected, nil
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil, fmt.Errorf("no table candidates")
|
||||
}
|
||||
|
||||
var hinted *sudokuobfs.Table
|
||||
for _, candidate := range candidates {
|
||||
if candidate == nil || candidate.Hint() != hello.TableHint {
|
||||
continue
|
||||
}
|
||||
hinted = candidate
|
||||
break
|
||||
}
|
||||
if hinted == nil {
|
||||
return nil, fmt.Errorf("unknown table hint: %d", hello.TableHint)
|
||||
}
|
||||
if hinted != selected && (!hinted.IsASCII || !selected.IsASCII) {
|
||||
return nil, fmt.Errorf("table hint %d mismatches probed uplink table", hello.TableHint)
|
||||
}
|
||||
return hinted, nil
|
||||
}
|
||||
|
||||
func (m *KIPServerHello) EncodePayload() []byte {
|
||||
var b bytes.Buffer
|
||||
b.Write(m.Nonce[:])
|
||||
b.Write(m.ServerPub[:])
|
||||
var f [4]byte
|
||||
binary.BigEndian.PutUint32(f[:], m.SelectedFeats)
|
||||
b.Write(f[:])
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func DecodeKIPServerHelloPayload(payload []byte) (*KIPServerHello, error) {
|
||||
const want = kipHelloNonceSize + kipHelloPubSize + 4
|
||||
if len(payload) != want {
|
||||
return nil, fmt.Errorf("%w: server hello bad len: %d", errKIP, len(payload))
|
||||
}
|
||||
var h KIPServerHello
|
||||
off := 0
|
||||
copy(h.Nonce[:], payload[off:off+kipHelloNonceSize])
|
||||
off += kipHelloNonceSize
|
||||
copy(h.ServerPub[:], payload[off:off+kipHelloPubSize])
|
||||
off += kipHelloPubSize
|
||||
h.SelectedFeats = binary.BigEndian.Uint32(payload[off : off+4])
|
||||
return &h, nil
|
||||
}
|
||||
|
||||
func writeFull(w io.Writer, b []byte) error {
|
||||
for len(b) > 0 {
|
||||
n, err := w.Write(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b = b[n:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
130
transport/sudoku/multiplex.go
Normal file
130
transport/sudoku/multiplex.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/sing-box/transport/sudoku/multiplex"
|
||||
)
|
||||
|
||||
// StartMultiplexClient upgrades an already-handshaked Sudoku tunnel into a multiplex session.
|
||||
func StartMultiplexClient(conn net.Conn) (*MultiplexClient, error) {
|
||||
if conn == nil {
|
||||
return nil, fmt.Errorf("nil conn")
|
||||
}
|
||||
|
||||
if err := WriteKIPMessage(conn, KIPTypeStartMux, nil); err != nil {
|
||||
return nil, fmt.Errorf("write mux start failed: %w", err)
|
||||
}
|
||||
|
||||
sess, err := multiplex.NewClientSession(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("start multiplex session failed: %w", err)
|
||||
}
|
||||
|
||||
return &MultiplexClient{sess: sess}, nil
|
||||
}
|
||||
|
||||
type MultiplexClient struct {
|
||||
sess *multiplex.Session
|
||||
}
|
||||
|
||||
// Dial opens a new logical stream, writes the target address, and returns the stream as net.Conn.
|
||||
func (c *MultiplexClient) Dial(ctx context.Context, targetAddress string) (net.Conn, error) {
|
||||
if c == nil || c.sess == nil || c.sess.IsClosed() {
|
||||
return nil, fmt.Errorf("multiplex session is closed")
|
||||
}
|
||||
if strings.TrimSpace(targetAddress) == "" {
|
||||
return nil, fmt.Errorf("target address cannot be empty")
|
||||
}
|
||||
|
||||
addrBuf, err := EncodeAddress(targetAddress)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode target address failed: %w", err)
|
||||
}
|
||||
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
stream, err := c.sess.OpenStream(addrBuf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stream, nil
|
||||
}
|
||||
|
||||
func (c *MultiplexClient) Close() error {
|
||||
if c == nil || c.sess == nil {
|
||||
return nil
|
||||
}
|
||||
return c.sess.Close()
|
||||
}
|
||||
|
||||
func (c *MultiplexClient) IsClosed() bool {
|
||||
if c == nil || c.sess == nil {
|
||||
return true
|
||||
}
|
||||
return c.sess.IsClosed()
|
||||
}
|
||||
|
||||
// AcceptMultiplexServer upgrades a server-side, already-handshaked Sudoku connection into a multiplex session.
|
||||
func AcceptMultiplexServer(conn net.Conn) (*MultiplexServer, error) {
|
||||
if conn == nil {
|
||||
return nil, fmt.Errorf("nil conn")
|
||||
}
|
||||
sess, err := multiplex.NewServerSession(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &MultiplexServer{sess: sess}, nil
|
||||
}
|
||||
|
||||
// MultiplexServer wraps a multiplex session created from a handshaked Sudoku tunnel connection.
|
||||
type MultiplexServer struct {
|
||||
sess *multiplex.Session
|
||||
}
|
||||
|
||||
func (s *MultiplexServer) AcceptStream() (net.Conn, error) {
|
||||
if s == nil || s.sess == nil {
|
||||
return nil, fmt.Errorf("nil session")
|
||||
}
|
||||
c, _, err := s.sess.AcceptStream()
|
||||
return c, err
|
||||
}
|
||||
|
||||
// AcceptTCP accepts a multiplex stream and returns the target address declared in the open frame.
|
||||
func (s *MultiplexServer) AcceptTCP() (net.Conn, string, error) {
|
||||
if s == nil || s.sess == nil {
|
||||
return nil, "", fmt.Errorf("nil session")
|
||||
}
|
||||
stream, payload, err := s.sess.AcceptStream()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
target, err := DecodeAddress(bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
_ = stream.Close()
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return stream, target, nil
|
||||
}
|
||||
|
||||
func (s *MultiplexServer) Close() error {
|
||||
if s == nil || s.sess == nil {
|
||||
return nil
|
||||
}
|
||||
return s.sess.Close()
|
||||
}
|
||||
|
||||
func (s *MultiplexServer) IsClosed() bool {
|
||||
if s == nil || s.sess == nil {
|
||||
return true
|
||||
}
|
||||
return s.sess.IsClosed()
|
||||
}
|
||||
493
transport/sudoku/multiplex/session.go
Normal file
493
transport/sudoku/multiplex/session.go
Normal file
@@ -0,0 +1,493 @@
|
||||
package multiplex
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
frameOpen byte = 0x01
|
||||
frameData byte = 0x02
|
||||
frameClose byte = 0x03
|
||||
frameReset byte = 0x04
|
||||
)
|
||||
|
||||
const (
|
||||
headerSize = 1 + 4 + 4
|
||||
maxFrameSize = 256 * 1024
|
||||
maxDataPayload = 32 * 1024
|
||||
)
|
||||
|
||||
type acceptEvent struct {
|
||||
stream *stream
|
||||
payload []byte
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
conn net.Conn
|
||||
|
||||
writeMu sync.Mutex
|
||||
|
||||
streamsMu sync.Mutex
|
||||
streams map[uint32]*stream
|
||||
nextID uint32
|
||||
|
||||
acceptCh chan acceptEvent
|
||||
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
}
|
||||
|
||||
func NewClientSession(conn net.Conn) (*Session, error) {
|
||||
if conn == nil {
|
||||
return nil, fmt.Errorf("nil conn")
|
||||
}
|
||||
s := &Session{
|
||||
conn: conn,
|
||||
streams: make(map[uint32]*stream),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
go s.readLoop()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func NewServerSession(conn net.Conn) (*Session, error) {
|
||||
if conn == nil {
|
||||
return nil, fmt.Errorf("nil conn")
|
||||
}
|
||||
s := &Session{
|
||||
conn: conn,
|
||||
streams: make(map[uint32]*stream),
|
||||
acceptCh: make(chan acceptEvent, 256),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
go s.readLoop()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Session) IsClosed() bool {
|
||||
if s == nil {
|
||||
return true
|
||||
}
|
||||
select {
|
||||
case <-s.closed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) closedErr() error {
|
||||
s.streamsMu.Lock()
|
||||
err := s.closeErr
|
||||
s.streamsMu.Unlock()
|
||||
if err == nil {
|
||||
return io.ErrClosedPipe
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Session) closeWithError(err error) {
|
||||
if err == nil {
|
||||
err = io.ErrClosedPipe
|
||||
}
|
||||
s.closeOnce.Do(func() {
|
||||
s.streamsMu.Lock()
|
||||
if s.closeErr == nil {
|
||||
s.closeErr = err
|
||||
}
|
||||
streams := make([]*stream, 0, len(s.streams))
|
||||
for _, st := range s.streams {
|
||||
streams = append(streams, st)
|
||||
}
|
||||
s.streams = make(map[uint32]*stream)
|
||||
s.streamsMu.Unlock()
|
||||
|
||||
for _, st := range streams {
|
||||
st.closeNoSend(err)
|
||||
}
|
||||
|
||||
close(s.closed)
|
||||
_ = s.conn.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Session) Close() error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.closeWithError(io.ErrClosedPipe)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) registerStream(st *stream) {
|
||||
s.streamsMu.Lock()
|
||||
s.streams[st.id] = st
|
||||
s.streamsMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Session) getStream(id uint32) *stream {
|
||||
s.streamsMu.Lock()
|
||||
st := s.streams[id]
|
||||
s.streamsMu.Unlock()
|
||||
return st
|
||||
}
|
||||
|
||||
func (s *Session) removeStream(id uint32) {
|
||||
s.streamsMu.Lock()
|
||||
delete(s.streams, id)
|
||||
s.streamsMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Session) nextStreamID() uint32 {
|
||||
s.streamsMu.Lock()
|
||||
s.nextID++
|
||||
id := s.nextID
|
||||
if id == 0 {
|
||||
s.nextID++
|
||||
id = s.nextID
|
||||
}
|
||||
s.streamsMu.Unlock()
|
||||
return id
|
||||
}
|
||||
|
||||
func (s *Session) sendFrame(frameType byte, streamID uint32, payload []byte) error {
|
||||
if s.IsClosed() {
|
||||
return s.closedErr()
|
||||
}
|
||||
if len(payload) > maxFrameSize {
|
||||
return fmt.Errorf("mux payload too large: %d", len(payload))
|
||||
}
|
||||
|
||||
var header [headerSize]byte
|
||||
header[0] = frameType
|
||||
binary.BigEndian.PutUint32(header[1:5], streamID)
|
||||
binary.BigEndian.PutUint32(header[5:9], uint32(len(payload)))
|
||||
|
||||
s.writeMu.Lock()
|
||||
defer s.writeMu.Unlock()
|
||||
|
||||
if err := writeAllChunks(s.conn, header[:], payload); err != nil {
|
||||
s.closeWithError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) sendReset(streamID uint32, msg string) {
|
||||
if msg == "" {
|
||||
msg = "reset"
|
||||
}
|
||||
_ = s.sendFrame(frameReset, streamID, []byte(msg))
|
||||
_ = s.sendFrame(frameClose, streamID, nil)
|
||||
}
|
||||
|
||||
func (s *Session) OpenStream(openPayload []byte) (net.Conn, error) {
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("nil session")
|
||||
}
|
||||
if s.IsClosed() {
|
||||
return nil, s.closedErr()
|
||||
}
|
||||
|
||||
streamID := s.nextStreamID()
|
||||
st := newStream(s, streamID)
|
||||
s.registerStream(st)
|
||||
|
||||
if err := s.sendFrame(frameOpen, streamID, openPayload); err != nil {
|
||||
st.closeNoSend(err)
|
||||
s.removeStream(streamID)
|
||||
return nil, fmt.Errorf("mux open failed: %w", err)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (s *Session) AcceptStream() (net.Conn, []byte, error) {
|
||||
if s == nil {
|
||||
return nil, nil, fmt.Errorf("nil session")
|
||||
}
|
||||
if s.acceptCh == nil {
|
||||
return nil, nil, fmt.Errorf("accept is not supported on client sessions")
|
||||
}
|
||||
select {
|
||||
case ev := <-s.acceptCh:
|
||||
return ev.stream, ev.payload, nil
|
||||
case <-s.closed:
|
||||
return nil, nil, s.closedErr()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) readLoop() {
|
||||
var header [headerSize]byte
|
||||
for {
|
||||
if _, err := io.ReadFull(s.conn, header[:]); err != nil {
|
||||
s.closeWithError(err)
|
||||
return
|
||||
}
|
||||
frameType := header[0]
|
||||
streamID := binary.BigEndian.Uint32(header[1:5])
|
||||
n := int(binary.BigEndian.Uint32(header[5:9]))
|
||||
if n < 0 || n > maxFrameSize {
|
||||
s.closeWithError(fmt.Errorf("invalid mux frame length: %d", n))
|
||||
return
|
||||
}
|
||||
|
||||
var payload []byte
|
||||
if n > 0 {
|
||||
payload = make([]byte, n)
|
||||
if _, err := io.ReadFull(s.conn, payload); err != nil {
|
||||
s.closeWithError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch frameType {
|
||||
case frameOpen:
|
||||
if s.acceptCh == nil {
|
||||
s.sendReset(streamID, "unexpected open")
|
||||
continue
|
||||
}
|
||||
if streamID == 0 {
|
||||
s.sendReset(streamID, "invalid stream id")
|
||||
continue
|
||||
}
|
||||
if existing := s.getStream(streamID); existing != nil {
|
||||
s.sendReset(streamID, "stream already exists")
|
||||
continue
|
||||
}
|
||||
st := newStream(s, streamID)
|
||||
s.registerStream(st)
|
||||
go func() {
|
||||
select {
|
||||
case s.acceptCh <- acceptEvent{stream: st, payload: payload}:
|
||||
case <-s.closed:
|
||||
st.closeNoSend(io.ErrClosedPipe)
|
||||
s.removeStream(streamID)
|
||||
}
|
||||
}()
|
||||
|
||||
case frameData:
|
||||
st := s.getStream(streamID)
|
||||
if st == nil {
|
||||
continue
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
continue
|
||||
}
|
||||
st.enqueue(payload)
|
||||
|
||||
case frameClose:
|
||||
st := s.getStream(streamID)
|
||||
if st == nil {
|
||||
continue
|
||||
}
|
||||
st.closeNoSend(io.EOF)
|
||||
s.removeStream(streamID)
|
||||
|
||||
case frameReset:
|
||||
st := s.getStream(streamID)
|
||||
if st == nil {
|
||||
continue
|
||||
}
|
||||
msg := trimASCII(payload)
|
||||
if msg == "" {
|
||||
msg = "reset"
|
||||
}
|
||||
st.closeNoSend(errors.New(msg))
|
||||
s.removeStream(streamID)
|
||||
|
||||
default:
|
||||
s.closeWithError(fmt.Errorf("unknown mux frame type: %d", frameType))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func trimASCII(b []byte) string {
|
||||
i := 0
|
||||
j := len(b)
|
||||
for i < j {
|
||||
c := b[i]
|
||||
if c != ' ' && c != '\n' && c != '\r' && c != '\t' {
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
for j > i {
|
||||
c := b[j-1]
|
||||
if c != ' ' && c != '\n' && c != '\r' && c != '\t' {
|
||||
break
|
||||
}
|
||||
j--
|
||||
}
|
||||
if i >= j {
|
||||
return ""
|
||||
}
|
||||
out := make([]byte, j-i)
|
||||
copy(out, b[i:j])
|
||||
return string(out)
|
||||
}
|
||||
|
||||
type stream struct {
|
||||
session *Session
|
||||
id uint32
|
||||
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
closed bool
|
||||
closeErr error
|
||||
readBuf []byte
|
||||
queue [][]byte
|
||||
|
||||
localAddr net.Addr
|
||||
remoteAddr net.Addr
|
||||
}
|
||||
|
||||
func newStream(session *Session, id uint32) *stream {
|
||||
st := &stream{
|
||||
session: session,
|
||||
id: id,
|
||||
localAddr: &net.TCPAddr{},
|
||||
remoteAddr: &net.TCPAddr{},
|
||||
}
|
||||
st.cond = sync.NewCond(&st.mu)
|
||||
return st
|
||||
}
|
||||
|
||||
func (c *stream) enqueue(payload []byte) {
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if len(c.readBuf) == 0 && len(c.queue) == 0 {
|
||||
c.readBuf = payload
|
||||
} else {
|
||||
c.queue = append(c.queue, payload)
|
||||
}
|
||||
c.cond.Signal()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *stream) closeNoSend(err error) {
|
||||
if err == nil {
|
||||
err = io.EOF
|
||||
}
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.closed = true
|
||||
if c.closeErr == nil {
|
||||
c.closeErr = err
|
||||
}
|
||||
c.cond.Broadcast()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *stream) closedErr() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.closeErr == nil {
|
||||
return io.ErrClosedPipe
|
||||
}
|
||||
return c.closeErr
|
||||
}
|
||||
|
||||
func (c *stream) Read(p []byte) (int, error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
for len(c.readBuf) == 0 && len(c.queue) == 0 && !c.closed {
|
||||
c.cond.Wait()
|
||||
}
|
||||
if len(c.readBuf) == 0 && len(c.queue) > 0 {
|
||||
c.readBuf = c.queue[0]
|
||||
c.queue = c.queue[1:]
|
||||
}
|
||||
if len(c.readBuf) == 0 && c.closed {
|
||||
if c.closeErr == nil {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
return 0, c.closeErr
|
||||
}
|
||||
|
||||
n := copy(p, c.readBuf)
|
||||
c.readBuf = c.readBuf[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (c *stream) Write(p []byte) (int, error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if c.session == nil || c.session.IsClosed() {
|
||||
if c.session != nil {
|
||||
return 0, c.session.closedErr()
|
||||
}
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
closed := c.closed
|
||||
c.mu.Unlock()
|
||||
if closed {
|
||||
return 0, c.closedErr()
|
||||
}
|
||||
|
||||
written := 0
|
||||
for len(p) > 0 {
|
||||
chunk := p
|
||||
if len(chunk) > maxDataPayload {
|
||||
chunk = p[:maxDataPayload]
|
||||
}
|
||||
if err := c.session.sendFrame(frameData, c.id, chunk); err != nil {
|
||||
return written, err
|
||||
}
|
||||
written += len(chunk)
|
||||
p = p[len(chunk):]
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func (c *stream) Close() error {
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
c.closed = true
|
||||
if c.closeErr == nil {
|
||||
c.closeErr = io.ErrClosedPipe
|
||||
}
|
||||
c.cond.Broadcast()
|
||||
c.mu.Unlock()
|
||||
|
||||
_ = c.session.sendFrame(frameClose, c.id, nil)
|
||||
c.session.removeStream(c.id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *stream) CloseWrite() error { return c.Close() }
|
||||
func (c *stream) CloseRead() error { return c.Close() }
|
||||
|
||||
func (c *stream) LocalAddr() net.Addr { return c.localAddr }
|
||||
func (c *stream) RemoteAddr() net.Addr { return c.remoteAddr }
|
||||
|
||||
func (c *stream) SetDeadline(t time.Time) error {
|
||||
_ = c.SetReadDeadline(t)
|
||||
_ = c.SetWriteDeadline(t)
|
||||
return nil
|
||||
}
|
||||
func (c *stream) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *stream) SetWriteDeadline(time.Time) error { return nil }
|
||||
19
transport/sudoku/multiplex/write_chunks.go
Normal file
19
transport/sudoku/multiplex/write_chunks.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package multiplex
|
||||
|
||||
import "io"
|
||||
|
||||
func writeAllChunks(w io.Writer, chunks ...[]byte) error {
|
||||
for _, chunk := range chunks {
|
||||
for len(chunk) > 0 {
|
||||
n, err := w.Write(chunk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
chunk = chunk[n:]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
161
transport/sudoku/obfs/httpmask/auth.go
Normal file
161
transport/sudoku/obfs/httpmask/auth.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package httpmask
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const (
|
||||
tunnelAuthHeaderKey = "Authorization"
|
||||
tunnelAuthHeaderPrefix = "Bearer "
|
||||
tunnelAuthQueryKey = "auth"
|
||||
)
|
||||
|
||||
type tunnelAuth struct {
|
||||
key [32]byte // derived HMAC key
|
||||
skew time.Duration
|
||||
}
|
||||
|
||||
func newTunnelAuth(key string, skew time.Duration) *tunnelAuth {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
if skew <= 0 {
|
||||
skew = 60 * time.Second
|
||||
}
|
||||
|
||||
// Domain separation: keep this HMAC key independent from other uses of cfg.Key.
|
||||
h := sha256.New()
|
||||
_, _ = h.Write([]byte("sudoku-httpmask-auth-v1:"))
|
||||
_, _ = h.Write([]byte(key))
|
||||
|
||||
var sum [32]byte
|
||||
h.Sum(sum[:0])
|
||||
|
||||
return &tunnelAuth{key: sum, skew: skew}
|
||||
}
|
||||
|
||||
func (a *tunnelAuth) token(mode TunnelMode, method, path string, now time.Time) string {
|
||||
if a == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
ts := now.Unix()
|
||||
sig := a.sign(mode, method, path, ts)
|
||||
|
||||
var buf [8 + 16]byte
|
||||
binary.BigEndian.PutUint64(buf[:8], uint64(ts))
|
||||
copy(buf[8:], sig[:])
|
||||
return base64.RawURLEncoding.EncodeToString(buf[:])
|
||||
}
|
||||
|
||||
func (a *tunnelAuth) verify(headers map[string]string, mode TunnelMode, method, path string, now time.Time) bool {
|
||||
if a == nil {
|
||||
return true
|
||||
}
|
||||
if headers == nil {
|
||||
return false
|
||||
}
|
||||
return a.verifyValue(headers["authorization"], mode, method, path, now)
|
||||
}
|
||||
|
||||
func (a *tunnelAuth) verifyValue(val string, mode TunnelMode, method, path string, now time.Time) bool {
|
||||
if a == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
val = strings.TrimSpace(val)
|
||||
if val == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Accept both "Bearer <token>" and raw token forms (for forward proxies / CDNs that may normalize headers).
|
||||
if len(val) > len(tunnelAuthHeaderPrefix) && strings.EqualFold(val[:len(tunnelAuthHeaderPrefix)], tunnelAuthHeaderPrefix) {
|
||||
val = strings.TrimSpace(val[len(tunnelAuthHeaderPrefix):])
|
||||
}
|
||||
if val == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
raw, err := base64.RawURLEncoding.DecodeString(val)
|
||||
if err != nil || len(raw) != 8+16 {
|
||||
return false
|
||||
}
|
||||
|
||||
ts := int64(binary.BigEndian.Uint64(raw[:8]))
|
||||
nowTS := now.Unix()
|
||||
delta := nowTS - ts
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
if delta > int64(a.skew.Seconds()) {
|
||||
return false
|
||||
}
|
||||
|
||||
want := a.sign(mode, method, path, ts)
|
||||
return subtle.ConstantTimeCompare(raw[8:], want[:]) == 1
|
||||
}
|
||||
|
||||
func (a *tunnelAuth) sign(mode TunnelMode, method, path string, ts int64) [16]byte {
|
||||
method = strings.ToUpper(strings.TrimSpace(method))
|
||||
if method == "" {
|
||||
method = "GET"
|
||||
}
|
||||
path = strings.TrimSpace(path)
|
||||
|
||||
var tsBuf [8]byte
|
||||
binary.BigEndian.PutUint64(tsBuf[:], uint64(ts))
|
||||
|
||||
mac := hmac.New(sha256.New, a.key[:])
|
||||
_, _ = mac.Write([]byte(mode))
|
||||
_, _ = mac.Write([]byte{0})
|
||||
_, _ = mac.Write([]byte(method))
|
||||
_, _ = mac.Write([]byte{0})
|
||||
_, _ = mac.Write([]byte(path))
|
||||
_, _ = mac.Write([]byte{0})
|
||||
_, _ = mac.Write(tsBuf[:])
|
||||
|
||||
var full [32]byte
|
||||
mac.Sum(full[:0])
|
||||
|
||||
var out [16]byte
|
||||
copy(out[:], full[:16])
|
||||
return out
|
||||
}
|
||||
|
||||
type httpHeaderSetter = http.Header
|
||||
|
||||
func applyTunnelAuthHeader(h httpHeaderSetter, auth *tunnelAuth, mode TunnelMode, method, path string) {
|
||||
if auth == nil || h == nil {
|
||||
return
|
||||
}
|
||||
token := auth.token(mode, method, path, time.Now())
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
h.Set(tunnelAuthHeaderKey, tunnelAuthHeaderPrefix+token)
|
||||
}
|
||||
|
||||
func applyTunnelAuth(req *http.Request, auth *tunnelAuth, mode TunnelMode, method, path string) {
|
||||
if auth == nil || req == nil {
|
||||
return
|
||||
}
|
||||
token := auth.token(mode, method, path, time.Now())
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
req.Header.Set(tunnelAuthHeaderKey, tunnelAuthHeaderPrefix+token)
|
||||
if req.URL != nil {
|
||||
q := req.URL.Query()
|
||||
q.Set(tunnelAuthQueryKey, token)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
}
|
||||
}
|
||||
174
transport/sudoku/obfs/httpmask/early_handshake.go
Normal file
174
transport/sudoku/obfs/httpmask/early_handshake.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package httpmask
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
tunnelEarlyDataQueryKey = "ed"
|
||||
tunnelEarlyDataHeader = "X-Sudoku-Early"
|
||||
)
|
||||
|
||||
type ClientEarlyHandshake struct {
|
||||
RequestPayload []byte
|
||||
HandleResponse func(payload []byte) error
|
||||
Ready func() bool
|
||||
WrapConn func(raw net.Conn) (net.Conn, error)
|
||||
}
|
||||
|
||||
type TunnelServerEarlyHandshake struct {
|
||||
Prepare func(payload []byte) (*PreparedServerEarlyHandshake, error)
|
||||
}
|
||||
|
||||
type PreparedServerEarlyHandshake struct {
|
||||
ResponsePayload []byte
|
||||
WrapConn func(raw net.Conn) (net.Conn, error)
|
||||
UserHash string
|
||||
}
|
||||
|
||||
type earlyHandshakeMeta interface {
|
||||
HTTPMaskEarlyHandshakeUserHash() string
|
||||
}
|
||||
|
||||
type earlyHandshakeConn struct {
|
||||
net.Conn
|
||||
userHash string
|
||||
}
|
||||
|
||||
func (c *earlyHandshakeConn) HTTPMaskEarlyHandshakeUserHash() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.userHash
|
||||
}
|
||||
|
||||
func wrapEarlyHandshakeConn(conn net.Conn, userHash string) net.Conn {
|
||||
if conn == nil {
|
||||
return nil
|
||||
}
|
||||
return &earlyHandshakeConn{Conn: conn, userHash: userHash}
|
||||
}
|
||||
|
||||
func EarlyHandshakeUserHash(conn net.Conn) (string, bool) {
|
||||
if conn == nil {
|
||||
return "", false
|
||||
}
|
||||
v, ok := conn.(earlyHandshakeMeta)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return v.HTTPMaskEarlyHandshakeUserHash(), true
|
||||
}
|
||||
|
||||
type authorizeResponse struct {
|
||||
token string
|
||||
earlyPayload []byte
|
||||
}
|
||||
|
||||
func isTunnelTokenByte(c byte) bool {
|
||||
return (c >= 'a' && c <= 'z') ||
|
||||
(c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9') ||
|
||||
c == '-' ||
|
||||
c == '_'
|
||||
}
|
||||
|
||||
func parseAuthorizeResponse(body []byte) (*authorizeResponse, error) {
|
||||
s := strings.TrimSpace(string(body))
|
||||
idx := strings.Index(s, "token=")
|
||||
if idx < 0 {
|
||||
return nil, errors.New("missing token")
|
||||
}
|
||||
s = s[idx+len("token="):]
|
||||
if s == "" {
|
||||
return nil, errors.New("empty token")
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if isTunnelTokenByte(c) {
|
||||
b.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
token := b.String()
|
||||
if token == "" {
|
||||
return nil, errors.New("empty token")
|
||||
}
|
||||
|
||||
out := &authorizeResponse{token: token}
|
||||
if earlyLine := findAuthorizeField(body, "ed="); earlyLine != "" {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(earlyLine)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode early authorize payload failed: %w", err)
|
||||
}
|
||||
out.earlyPayload = decoded
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func findAuthorizeField(body []byte, prefix string) string {
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(line, prefix))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func setEarlyDataQuery(rawURL string, payload []byte) (string, error) {
|
||||
if len(payload) == 0 {
|
||||
return rawURL, nil
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set(tunnelEarlyDataQueryKey, base64.RawURLEncoding.EncodeToString(payload))
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func parseEarlyDataQuery(u *url.URL) ([]byte, error) {
|
||||
if u == nil {
|
||||
return nil, nil
|
||||
}
|
||||
val := strings.TrimSpace(u.Query().Get(tunnelEarlyDataQueryKey))
|
||||
if val == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return base64.RawURLEncoding.DecodeString(val)
|
||||
}
|
||||
|
||||
func applyEarlyHandshakeOrUpgrade(raw net.Conn, opts TunnelDialOptions) (net.Conn, error) {
|
||||
out := raw
|
||||
if opts.EarlyHandshake != nil && opts.EarlyHandshake.WrapConn != nil && (opts.EarlyHandshake.Ready == nil || opts.EarlyHandshake.Ready()) {
|
||||
wrapped, err := opts.EarlyHandshake.WrapConn(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wrapped != nil {
|
||||
out = wrapped
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if opts.Upgrade != nil {
|
||||
wrapped, err := opts.Upgrade(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wrapped != nil {
|
||||
out = wrapped
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
229
transport/sudoku/obfs/httpmask/halfpipe.go
Normal file
229
transport/sudoku/obfs/httpmask/halfpipe.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package httpmask
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type pipeDeadline struct {
|
||||
mu sync.Mutex
|
||||
timer *time.Timer
|
||||
cancel chan struct{}
|
||||
}
|
||||
|
||||
func makePipeDeadline() pipeDeadline {
|
||||
return pipeDeadline{cancel: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (d *pipeDeadline) set(t time.Time) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.timer != nil && !d.timer.Stop() {
|
||||
<-d.cancel
|
||||
}
|
||||
d.timer = nil
|
||||
|
||||
closed := isClosedPipeChan(d.cancel)
|
||||
if t.IsZero() {
|
||||
if closed {
|
||||
d.cancel = make(chan struct{})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if dur := time.Until(t); dur > 0 {
|
||||
if closed {
|
||||
d.cancel = make(chan struct{})
|
||||
}
|
||||
d.timer = time.AfterFunc(dur, func() {
|
||||
close(d.cancel)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !closed {
|
||||
close(d.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *pipeDeadline) wait() <-chan struct{} {
|
||||
d.mu.Lock()
|
||||
ch := d.cancel
|
||||
d.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func isClosedPipeChan(ch <-chan struct{}) bool {
|
||||
select {
|
||||
case <-ch:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type halfPipeAddr struct{}
|
||||
|
||||
func (halfPipeAddr) Network() string { return "pipe" }
|
||||
func (halfPipeAddr) String() string { return "pipe" }
|
||||
|
||||
type halfPipeConn struct {
|
||||
wrMu sync.Mutex
|
||||
|
||||
rdRx <-chan []byte
|
||||
rdTx chan<- int
|
||||
|
||||
wrTx chan<- []byte
|
||||
wrRx <-chan int
|
||||
|
||||
readOnce sync.Once
|
||||
writeOnce sync.Once
|
||||
|
||||
localReadDone chan struct{}
|
||||
localWriteDone chan struct{}
|
||||
|
||||
remoteReadDone <-chan struct{}
|
||||
remoteWriteDone <-chan struct{}
|
||||
|
||||
readDeadline pipeDeadline
|
||||
writeDeadline pipeDeadline
|
||||
}
|
||||
|
||||
func newHalfPipe() (net.Conn, net.Conn) {
|
||||
cb1 := make(chan []byte)
|
||||
cb2 := make(chan []byte)
|
||||
cn1 := make(chan int)
|
||||
cn2 := make(chan int)
|
||||
|
||||
r1 := make(chan struct{})
|
||||
w1 := make(chan struct{})
|
||||
r2 := make(chan struct{})
|
||||
w2 := make(chan struct{})
|
||||
|
||||
c1 := &halfPipeConn{
|
||||
rdRx: cb1,
|
||||
rdTx: cn1,
|
||||
wrTx: cb2,
|
||||
wrRx: cn2,
|
||||
|
||||
localReadDone: r1,
|
||||
localWriteDone: w1,
|
||||
remoteReadDone: r2,
|
||||
remoteWriteDone: w2,
|
||||
|
||||
readDeadline: makePipeDeadline(),
|
||||
writeDeadline: makePipeDeadline(),
|
||||
}
|
||||
c2 := &halfPipeConn{
|
||||
rdRx: cb2,
|
||||
rdTx: cn2,
|
||||
wrTx: cb1,
|
||||
wrRx: cn1,
|
||||
|
||||
localReadDone: r2,
|
||||
localWriteDone: w2,
|
||||
remoteReadDone: r1,
|
||||
remoteWriteDone: w1,
|
||||
|
||||
readDeadline: makePipeDeadline(),
|
||||
writeDeadline: makePipeDeadline(),
|
||||
}
|
||||
return c1, c2
|
||||
}
|
||||
|
||||
func (*halfPipeConn) LocalAddr() net.Addr { return halfPipeAddr{} }
|
||||
func (*halfPipeConn) RemoteAddr() net.Addr { return halfPipeAddr{} }
|
||||
|
||||
func (c *halfPipeConn) Read(p []byte) (int, error) {
|
||||
switch {
|
||||
case isClosedPipeChan(c.localReadDone):
|
||||
return 0, io.ErrClosedPipe
|
||||
case isClosedPipeChan(c.remoteWriteDone):
|
||||
return 0, io.EOF
|
||||
case isClosedPipeChan(c.readDeadline.wait()):
|
||||
return 0, os.ErrDeadlineExceeded
|
||||
}
|
||||
|
||||
select {
|
||||
case b := <-c.rdRx:
|
||||
n := copy(p, b)
|
||||
c.rdTx <- n
|
||||
return n, nil
|
||||
case <-c.localReadDone:
|
||||
return 0, io.ErrClosedPipe
|
||||
case <-c.remoteWriteDone:
|
||||
return 0, io.EOF
|
||||
case <-c.readDeadline.wait():
|
||||
return 0, os.ErrDeadlineExceeded
|
||||
}
|
||||
}
|
||||
|
||||
func (c *halfPipeConn) Write(p []byte) (int, error) {
|
||||
switch {
|
||||
case isClosedPipeChan(c.localWriteDone):
|
||||
return 0, io.ErrClosedPipe
|
||||
case isClosedPipeChan(c.remoteReadDone):
|
||||
return 0, io.ErrClosedPipe
|
||||
case isClosedPipeChan(c.writeDeadline.wait()):
|
||||
return 0, os.ErrDeadlineExceeded
|
||||
}
|
||||
|
||||
c.wrMu.Lock()
|
||||
defer c.wrMu.Unlock()
|
||||
|
||||
var (
|
||||
total int
|
||||
rest = p
|
||||
)
|
||||
for once := true; once || len(rest) > 0; once = false {
|
||||
select {
|
||||
case c.wrTx <- rest:
|
||||
n := <-c.wrRx
|
||||
rest = rest[n:]
|
||||
total += n
|
||||
case <-c.localWriteDone:
|
||||
return total, io.ErrClosedPipe
|
||||
case <-c.remoteReadDone:
|
||||
return total, io.ErrClosedPipe
|
||||
case <-c.writeDeadline.wait():
|
||||
return total, os.ErrDeadlineExceeded
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (c *halfPipeConn) CloseWrite() error {
|
||||
c.writeOnce.Do(func() { close(c.localWriteDone) })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *halfPipeConn) CloseRead() error {
|
||||
c.readOnce.Do(func() { close(c.localReadDone) })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *halfPipeConn) Close() error {
|
||||
_ = c.CloseRead()
|
||||
_ = c.CloseWrite()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *halfPipeConn) SetDeadline(t time.Time) error {
|
||||
c.readDeadline.set(t)
|
||||
c.writeDeadline.set(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *halfPipeConn) SetReadDeadline(t time.Time) error {
|
||||
c.readDeadline.set(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *halfPipeConn) SetWriteDeadline(t time.Time) error {
|
||||
c.writeDeadline.set(t)
|
||||
return nil
|
||||
}
|
||||
252
transport/sudoku/obfs/httpmask/masker.go
Normal file
252
transport/sudoku/obfs/httpmask/masker.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package httpmask
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
userAgents = []string{
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1",
|
||||
"Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36",
|
||||
}
|
||||
accepts = []string{
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"application/json, text/plain, */*",
|
||||
"application/octet-stream",
|
||||
"*/*",
|
||||
}
|
||||
acceptLanguages = []string{
|
||||
"en-US,en;q=0.9",
|
||||
"en-GB,en;q=0.9",
|
||||
"zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
}
|
||||
acceptEncodings = []string{
|
||||
"gzip, deflate, br",
|
||||
"gzip, deflate",
|
||||
"br, gzip, deflate",
|
||||
}
|
||||
paths = []string{
|
||||
"/api/v1/upload",
|
||||
"/data/sync",
|
||||
"/uploads/raw",
|
||||
"/api/report",
|
||||
"/feed/update",
|
||||
"/v2/events",
|
||||
"/v1/telemetry",
|
||||
"/session",
|
||||
"/stream",
|
||||
"/ws",
|
||||
}
|
||||
contentTypes = []string{
|
||||
"application/octet-stream",
|
||||
"application/x-protobuf",
|
||||
"application/json",
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
rngPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
return rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
},
|
||||
}
|
||||
headerBufPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
b := make([]byte, 0, 1024)
|
||||
return &b
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// LooksLikeHTTPRequestStart reports whether peek4 looks like a supported HTTP/1.x request method prefix.
|
||||
func LooksLikeHTTPRequestStart(peek4 []byte) bool {
|
||||
if len(peek4) < 4 {
|
||||
return false
|
||||
}
|
||||
// Common methods: "GET ", "POST", "HEAD", "PUT ", "OPTI" (OPTIONS), "PATC" (PATCH), "DELE" (DELETE)
|
||||
return bytes.Equal(peek4, []byte("GET ")) ||
|
||||
bytes.Equal(peek4, []byte("POST")) ||
|
||||
bytes.Equal(peek4, []byte("HEAD")) ||
|
||||
bytes.Equal(peek4, []byte("PUT ")) ||
|
||||
bytes.Equal(peek4, []byte("OPTI")) ||
|
||||
bytes.Equal(peek4, []byte("PATC")) ||
|
||||
bytes.Equal(peek4, []byte("DELE"))
|
||||
}
|
||||
|
||||
func trimPortForHost(host string) string {
|
||||
if host == "" {
|
||||
return host
|
||||
}
|
||||
// Accept "example.com:443" / "1.2.3.4:443" / "[::1]:443"
|
||||
h, _, err := net.SplitHostPort(host)
|
||||
if err == nil && h != "" {
|
||||
return h
|
||||
}
|
||||
// If it's not in host:port form, keep as-is.
|
||||
return host
|
||||
}
|
||||
|
||||
func appendCommonHeaders(buf []byte, host string, r *rand.Rand) []byte {
|
||||
ua := userAgents[r.Intn(len(userAgents))]
|
||||
accept := accepts[r.Intn(len(accepts))]
|
||||
lang := acceptLanguages[r.Intn(len(acceptLanguages))]
|
||||
enc := acceptEncodings[r.Intn(len(acceptEncodings))]
|
||||
|
||||
buf = append(buf, "Host: "...)
|
||||
buf = append(buf, host...)
|
||||
buf = append(buf, "\r\nUser-Agent: "...)
|
||||
buf = append(buf, ua...)
|
||||
buf = append(buf, "\r\nAccept: "...)
|
||||
buf = append(buf, accept...)
|
||||
buf = append(buf, "\r\nAccept-Language: "...)
|
||||
buf = append(buf, lang...)
|
||||
buf = append(buf, "\r\nAccept-Encoding: "...)
|
||||
buf = append(buf, enc...)
|
||||
buf = append(buf, "\r\nConnection: keep-alive\r\n"...)
|
||||
|
||||
// A couple of common cache headers; keep them static for simplicity.
|
||||
buf = append(buf, "Cache-Control: no-cache\r\nPragma: no-cache\r\n"...)
|
||||
return buf
|
||||
}
|
||||
|
||||
// WriteRandomRequestHeader writes a plausible HTTP/1.1 request header as a mask.
|
||||
func WriteRandomRequestHeader(w io.Writer, host string) error {
|
||||
return WriteRandomRequestHeaderWithPathRoot(w, host, "")
|
||||
}
|
||||
|
||||
// WriteRandomRequestHeaderWithPathRoot is like WriteRandomRequestHeader but prefixes all paths with pathRoot.
|
||||
// pathRoot must be a single segment (e.g. "aabbcc"); invalid inputs are treated as empty (disabled).
|
||||
func WriteRandomRequestHeaderWithPathRoot(w io.Writer, host string, pathRoot string) error {
|
||||
// Get RNG from pool
|
||||
r := rngPool.Get().(*rand.Rand)
|
||||
defer rngPool.Put(r)
|
||||
|
||||
path := joinPathRoot(pathRoot, paths[r.Intn(len(paths))])
|
||||
ctype := contentTypes[r.Intn(len(contentTypes))]
|
||||
|
||||
// Use buffer pool
|
||||
bufPtr := headerBufPool.Get().(*[]byte)
|
||||
buf := *bufPtr
|
||||
buf = buf[:0]
|
||||
defer func() {
|
||||
if cap(buf) <= 4096 {
|
||||
*bufPtr = buf
|
||||
headerBufPool.Put(bufPtr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Weighted template selection. Keep a conservative default (POST w/ Content-Length),
|
||||
// but occasionally rotate to other realistic templates (e.g. WebSocket upgrade).
|
||||
switch r.Intn(10) {
|
||||
case 0, 1: // ~20% WebSocket-like upgrade
|
||||
hostNoPort := trimPortForHost(host)
|
||||
var keyBytes [16]byte
|
||||
for i := 0; i < len(keyBytes); i++ {
|
||||
keyBytes[i] = byte(r.Intn(256))
|
||||
}
|
||||
wsKey := base64.StdEncoding.EncodeToString(keyBytes[:])
|
||||
|
||||
buf = append(buf, "GET "...)
|
||||
buf = append(buf, path...)
|
||||
buf = append(buf, " HTTP/1.1\r\n"...)
|
||||
buf = appendCommonHeaders(buf, host, r)
|
||||
buf = append(buf, "Upgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: "...)
|
||||
buf = append(buf, wsKey...)
|
||||
buf = append(buf, "\r\nOrigin: https://"...)
|
||||
buf = append(buf, hostNoPort...)
|
||||
buf = append(buf, "\r\n\r\n"...)
|
||||
default: // ~80% POST upload
|
||||
// Random Content-Length: 4KB–10MB. Small enough to look plausible, large enough
|
||||
// to justify long-lived writes on keep-alive connections.
|
||||
const minCL = int64(4 * 1024)
|
||||
const maxCL = int64(10 * 1024 * 1024)
|
||||
contentLength := minCL + r.Int63n(maxCL-minCL+1)
|
||||
|
||||
buf = append(buf, "POST "...)
|
||||
buf = append(buf, path...)
|
||||
buf = append(buf, " HTTP/1.1\r\n"...)
|
||||
buf = appendCommonHeaders(buf, host, r)
|
||||
buf = append(buf, "Content-Type: "...)
|
||||
buf = append(buf, ctype...)
|
||||
buf = append(buf, "\r\nContent-Length: "...)
|
||||
buf = strconv.AppendInt(buf, contentLength, 10)
|
||||
// A couple of extra headers seen in real clients.
|
||||
if r.Intn(2) == 0 {
|
||||
buf = append(buf, "\r\nX-Requested-With: XMLHttpRequest"...)
|
||||
}
|
||||
if r.Intn(3) == 0 {
|
||||
buf = append(buf, "\r\nReferer: https://"...)
|
||||
buf = append(buf, trimPortForHost(host)...)
|
||||
buf = append(buf, "/"...)
|
||||
}
|
||||
buf = append(buf, "\r\n\r\n"...)
|
||||
}
|
||||
|
||||
_, err := w.Write(buf)
|
||||
return err
|
||||
}
|
||||
|
||||
// ConsumeHeader 读取并消耗 HTTP 头部,返回消耗的数据和剩余的 reader 数据
|
||||
// 如果不是 POST 请求或格式严重错误,返回 error
|
||||
func ConsumeHeader(r *bufio.Reader) ([]byte, error) {
|
||||
var consumed bytes.Buffer
|
||||
|
||||
// 1. 读取请求行
|
||||
// Use ReadSlice to avoid allocation if line fits in buffer
|
||||
line, err := r.ReadSlice('\n')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
consumed.Write(line)
|
||||
|
||||
// Basic method validation: accept common HTTP/1.x methods used by our masker.
|
||||
// Keep it strict enough to reject obvious garbage.
|
||||
switch {
|
||||
case bytes.HasPrefix(line, []byte("POST ")),
|
||||
bytes.HasPrefix(line, []byte("GET ")),
|
||||
bytes.HasPrefix(line, []byte("HEAD ")),
|
||||
bytes.HasPrefix(line, []byte("PUT ")),
|
||||
bytes.HasPrefix(line, []byte("DELETE ")),
|
||||
bytes.HasPrefix(line, []byte("OPTIONS ")),
|
||||
bytes.HasPrefix(line, []byte("PATCH ")):
|
||||
default:
|
||||
return consumed.Bytes(), fmt.Errorf("invalid method or garbage: %s", strings.TrimSpace(string(line)))
|
||||
}
|
||||
|
||||
// 2. 循环读取头部,直到遇到空行
|
||||
for {
|
||||
line, err = r.ReadSlice('\n')
|
||||
if err != nil {
|
||||
return consumed.Bytes(), err
|
||||
}
|
||||
consumed.Write(line)
|
||||
|
||||
// Check for empty line (\r\n or \n)
|
||||
// ReadSlice includes the delimiter
|
||||
n := len(line)
|
||||
if n == 2 && line[0] == '\r' && line[1] == '\n' {
|
||||
return consumed.Bytes(), nil
|
||||
}
|
||||
if n == 1 && line[0] == '\n' {
|
||||
return consumed.Bytes(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
52
transport/sudoku/obfs/httpmask/pathroot.go
Normal file
52
transport/sudoku/obfs/httpmask/pathroot.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package httpmask
|
||||
|
||||
import "strings"
|
||||
|
||||
// normalizePathRoot normalizes the configured path root into "/<segment>" form.
|
||||
//
|
||||
// It is intentionally strict: only a single path segment is allowed, consisting of
|
||||
// [A-Za-z0-9_-]. Invalid inputs are treated as empty (disabled).
|
||||
func normalizePathRoot(root string) string {
|
||||
root = strings.TrimSpace(root)
|
||||
root = strings.Trim(root, "/")
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
for i := 0; i < len(root); i++ {
|
||||
c := root[i]
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z':
|
||||
case c >= 'A' && c <= 'Z':
|
||||
case c >= '0' && c <= '9':
|
||||
case c == '_' || c == '-':
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return "/" + root
|
||||
}
|
||||
|
||||
func joinPathRoot(root, path string) string {
|
||||
root = normalizePathRoot(root)
|
||||
if root == "" {
|
||||
return path
|
||||
}
|
||||
if path == "" {
|
||||
return root
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return root + path
|
||||
}
|
||||
|
||||
func stripPathRoot(root, fullPath string) (string, bool) {
|
||||
root = normalizePathRoot(root)
|
||||
if root == "" {
|
||||
return fullPath, true
|
||||
}
|
||||
if !strings.HasPrefix(fullPath, root+"/") {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimPrefix(fullPath, root), true
|
||||
}
|
||||
2442
transport/sudoku/obfs/httpmask/tunnel.go
Normal file
2442
transport/sudoku/obfs/httpmask/tunnel.go
Normal file
File diff suppressed because it is too large
Load Diff
190
transport/sudoku/obfs/httpmask/tunnel_ws.go
Normal file
190
transport/sudoku/obfs/httpmask/tunnel_ws.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package httpmask
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
mrand "math/rand"
|
||||
"net"
|
||||
stdhttp "net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gobwas/ws"
|
||||
)
|
||||
|
||||
func normalizeWSSchemeFromAddress(serverAddress string, tlsEnabled bool) (string, string) {
|
||||
addr := strings.TrimSpace(serverAddress)
|
||||
if strings.Contains(addr, "://") {
|
||||
if u, err := url.Parse(addr); err == nil && u != nil {
|
||||
switch strings.ToLower(strings.TrimSpace(u.Scheme)) {
|
||||
case "ws":
|
||||
return "ws", u.Host
|
||||
case "wss":
|
||||
return "wss", u.Host
|
||||
}
|
||||
}
|
||||
}
|
||||
if tlsEnabled {
|
||||
return "wss", addr
|
||||
}
|
||||
return "ws", addr
|
||||
}
|
||||
|
||||
func normalizeWSDialTarget(serverAddress string, tlsEnabled bool, hostOverride string) (scheme, urlHost, dialAddr, serverName string, err error) {
|
||||
scheme, addr := normalizeWSSchemeFromAddress(serverAddress, tlsEnabled)
|
||||
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
// Allow ws(s)://host without port.
|
||||
if strings.Contains(addr, ":") {
|
||||
return "", "", "", "", fmt.Errorf("invalid server address %q: %w", serverAddress, err)
|
||||
}
|
||||
switch scheme {
|
||||
case "wss":
|
||||
port = "443"
|
||||
default:
|
||||
port = "80"
|
||||
}
|
||||
host = addr
|
||||
}
|
||||
|
||||
if hostOverride != "" {
|
||||
// Allow "example.com" or "example.com:443"
|
||||
if h, p, splitErr := net.SplitHostPort(hostOverride); splitErr == nil {
|
||||
if h != "" {
|
||||
hostOverride = h
|
||||
}
|
||||
if p != "" {
|
||||
port = p
|
||||
}
|
||||
}
|
||||
serverName = hostOverride
|
||||
urlHost = net.JoinHostPort(hostOverride, port)
|
||||
} else {
|
||||
serverName = host
|
||||
urlHost = net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
dialAddr = net.JoinHostPort(host, port)
|
||||
return scheme, urlHost, dialAddr, trimPortForHost(serverName), nil
|
||||
}
|
||||
|
||||
func applyWSHeaders(h stdhttp.Header, host string) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
r := rngPool.Get().(*mrand.Rand)
|
||||
ua := userAgents[r.Intn(len(userAgents))]
|
||||
accept := accepts[r.Intn(len(accepts))]
|
||||
lang := acceptLanguages[r.Intn(len(acceptLanguages))]
|
||||
enc := acceptEncodings[r.Intn(len(acceptEncodings))]
|
||||
rngPool.Put(r)
|
||||
|
||||
h.Set("User-Agent", ua)
|
||||
h.Set("Accept", accept)
|
||||
h.Set("Accept-Language", lang)
|
||||
h.Set("Accept-Encoding", enc)
|
||||
h.Set("Cache-Control", "no-cache")
|
||||
h.Set("Pragma", "no-cache")
|
||||
h.Set("X-Sudoku-Tunnel", string(TunnelModeWS))
|
||||
h.Set("X-Sudoku-Version", "1")
|
||||
}
|
||||
|
||||
func dialWS(ctx context.Context, serverAddress string, opts TunnelDialOptions) (net.Conn, error) {
|
||||
if opts.DialContext == nil {
|
||||
panic("httpmask: DialContext is nil")
|
||||
}
|
||||
|
||||
scheme, urlHost, dialAddr, _, err := normalizeWSDialTarget(serverAddress, opts.TLSConfig != nil, opts.HostOverride)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpScheme := "http"
|
||||
if scheme == "wss" {
|
||||
httpScheme = "https"
|
||||
}
|
||||
headerHost := canonicalHeaderHost(urlHost, httpScheme)
|
||||
auth := newTunnelAuth(opts.AuthKey, 0)
|
||||
|
||||
u := &url.URL{
|
||||
Scheme: scheme,
|
||||
Host: urlHost,
|
||||
Path: joinPathRoot(opts.PathRoot, "/ws"),
|
||||
}
|
||||
if opts.EarlyHandshake != nil && len(opts.EarlyHandshake.RequestPayload) > 0 {
|
||||
rawURL, err := setEarlyDataQuery(u.String(), opts.EarlyHandshake.RequestPayload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u, err = url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
header := make(stdhttp.Header)
|
||||
applyWSHeaders(header, headerHost)
|
||||
|
||||
if auth != nil {
|
||||
token := auth.token(TunnelModeWS, stdhttp.MethodGet, "/ws", time.Now())
|
||||
if token != "" {
|
||||
header.Set("Authorization", "Bearer "+token)
|
||||
q := u.Query()
|
||||
q.Set(tunnelAuthQueryKey, token)
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
}
|
||||
|
||||
d := ws.Dialer{
|
||||
Host: headerHost,
|
||||
Header: ws.HandshakeHeaderHTTP(header),
|
||||
OnHeader: func(key, value []byte) error {
|
||||
if !strings.EqualFold(string(key), tunnelEarlyDataHeader) || opts.EarlyHandshake == nil || opts.EarlyHandshake.HandleResponse == nil {
|
||||
return nil
|
||||
}
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(string(value)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return opts.EarlyHandshake.HandleResponse(decoded)
|
||||
},
|
||||
NetDial: func(dialCtx context.Context, network, addr string) (net.Conn, error) {
|
||||
if addr == urlHost {
|
||||
addr = dialAddr
|
||||
}
|
||||
return opts.DialContext(dialCtx, network, addr)
|
||||
},
|
||||
}
|
||||
if scheme == "wss" {
|
||||
if opts.TLSConfig == nil {
|
||||
return nil, fmt.Errorf("httpmask: TLSConfig is required for wss")
|
||||
}
|
||||
d.TLSClient = func(conn net.Conn, hostname string) net.Conn {
|
||||
tlsConn, _ := opts.TLSConfig.Client(conn)
|
||||
return tlsConn
|
||||
}
|
||||
}
|
||||
|
||||
conn, br, _, err := d.Dial(ctx, u.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if br != nil && br.Buffered() > 0 {
|
||||
pre := make([]byte, br.Buffered())
|
||||
_, _ = io.ReadFull(br, pre)
|
||||
conn = newPreBufferedConn(conn, pre)
|
||||
}
|
||||
|
||||
wsConn := newWSStreamConn(conn, ws.StateClientSide)
|
||||
upgraded, err := applyEarlyHandshakeOrUpgrade(wsConn, opts)
|
||||
if err != nil {
|
||||
_ = wsConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return upgraded, nil
|
||||
}
|
||||
109
transport/sudoku/obfs/httpmask/tunnel_ws_server.go
Normal file
109
transport/sudoku/obfs/httpmask/tunnel_ws_server.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package httpmask
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gobwas/ws"
|
||||
)
|
||||
|
||||
func looksLikeWebSocketUpgrade(headers map[string]string) bool {
|
||||
if headers == nil {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(headers["upgrade"]), "websocket") {
|
||||
return false
|
||||
}
|
||||
conn := headers["connection"]
|
||||
for _, part := range strings.Split(conn, ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(part), "upgrade") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *TunnelServer) handleWS(rawConn net.Conn, req *httpRequestHeader, headerBytes []byte, buffered []byte) (HandleResult, net.Conn, error) {
|
||||
rejectOrReply := func(code int, body string) (HandleResult, net.Conn, error) {
|
||||
if s.passThroughOnReject {
|
||||
prefix := make([]byte, 0, len(headerBytes)+len(buffered))
|
||||
prefix = append(prefix, headerBytes...)
|
||||
prefix = append(prefix, buffered...)
|
||||
return HandlePassThrough, newRejectedPreBufferedConn(rawConn, prefix), nil
|
||||
}
|
||||
_ = writeSimpleHTTPResponse(rawConn, code, body)
|
||||
_ = rawConn.Close()
|
||||
return HandleDone, nil, nil
|
||||
}
|
||||
|
||||
u, err := url.ParseRequestURI(req.target)
|
||||
if err != nil {
|
||||
return rejectOrReply(http.StatusBadRequest, "bad request")
|
||||
}
|
||||
|
||||
path, ok := stripPathRoot(s.pathRoot, u.Path)
|
||||
if !ok || path != "/ws" {
|
||||
return rejectOrReply(http.StatusNotFound, "not found")
|
||||
}
|
||||
if strings.ToUpper(strings.TrimSpace(req.method)) != http.MethodGet {
|
||||
return rejectOrReply(http.StatusBadRequest, "bad request")
|
||||
}
|
||||
if !looksLikeWebSocketUpgrade(req.headers) {
|
||||
return rejectOrReply(http.StatusBadRequest, "bad request")
|
||||
}
|
||||
|
||||
authVal := req.headers["authorization"]
|
||||
if authVal == "" {
|
||||
authVal = u.Query().Get(tunnelAuthQueryKey)
|
||||
}
|
||||
if !s.auth.verifyValue(authVal, TunnelModeWS, req.method, path, time.Now()) {
|
||||
return rejectOrReply(http.StatusNotFound, "not found")
|
||||
}
|
||||
|
||||
earlyPayload, err := parseEarlyDataQuery(u)
|
||||
if err != nil {
|
||||
return rejectOrReply(http.StatusBadRequest, "bad request")
|
||||
}
|
||||
var prepared *PreparedServerEarlyHandshake
|
||||
if len(earlyPayload) > 0 && s.earlyHandshake != nil && s.earlyHandshake.Prepare != nil {
|
||||
prepared, err = s.earlyHandshake.Prepare(earlyPayload)
|
||||
if err != nil {
|
||||
return rejectOrReply(http.StatusNotFound, "not found")
|
||||
}
|
||||
}
|
||||
|
||||
prefix := make([]byte, 0, len(headerBytes)+len(buffered))
|
||||
prefix = append(prefix, headerBytes...)
|
||||
prefix = append(prefix, buffered...)
|
||||
wsConnRaw := newPreBufferedConn(rawConn, prefix)
|
||||
|
||||
upgrader := ws.Upgrader{}
|
||||
if prepared != nil && len(prepared.ResponsePayload) > 0 {
|
||||
upgrader.OnBeforeUpgrade = func() (ws.HandshakeHeader, error) {
|
||||
h := http.Header{}
|
||||
h.Set(tunnelEarlyDataHeader, base64.RawURLEncoding.EncodeToString(prepared.ResponsePayload))
|
||||
return ws.HandshakeHeaderHTTP(h), nil
|
||||
}
|
||||
}
|
||||
if _, err := upgrader.Upgrade(wsConnRaw); err != nil {
|
||||
_ = rawConn.Close()
|
||||
return HandleDone, nil, nil
|
||||
}
|
||||
|
||||
outConn := net.Conn(newWSStreamConn(wsConnRaw, ws.StateServerSide))
|
||||
if prepared != nil && prepared.WrapConn != nil {
|
||||
wrapped, err := prepared.WrapConn(outConn)
|
||||
if err != nil {
|
||||
_ = outConn.Close()
|
||||
return HandleDone, nil, nil
|
||||
}
|
||||
if wrapped != nil {
|
||||
outConn = wrapEarlyHandshakeConn(wrapped, prepared.UserHash)
|
||||
}
|
||||
}
|
||||
return HandleStartTunnel, outConn, nil
|
||||
}
|
||||
78
transport/sudoku/obfs/httpmask/ws_stream_conn.go
Normal file
78
transport/sudoku/obfs/httpmask/ws_stream_conn.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package httpmask
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
|
||||
"github.com/gobwas/ws"
|
||||
"github.com/gobwas/ws/wsutil"
|
||||
)
|
||||
|
||||
type wsStreamConn struct {
|
||||
net.Conn
|
||||
state ws.State
|
||||
reader *wsutil.Reader
|
||||
controlHandler wsutil.FrameHandlerFunc
|
||||
}
|
||||
|
||||
func newWSStreamConn(conn net.Conn, state ws.State) net.Conn {
|
||||
controlHandler := wsutil.ControlFrameHandler(conn, state)
|
||||
return &wsStreamConn{
|
||||
Conn: conn,
|
||||
state: state,
|
||||
reader: &wsutil.Reader{
|
||||
Source: conn,
|
||||
State: state,
|
||||
},
|
||||
controlHandler: controlHandler,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *wsStreamConn) Read(b []byte) (n int, err error) {
|
||||
defer func() {
|
||||
if v := recover(); v != nil {
|
||||
err = fmt.Errorf("websocket error: %v", v)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
n, err = c.reader.Read(b)
|
||||
if errors.Is(err, io.EOF) {
|
||||
err = nil
|
||||
}
|
||||
if !errors.Is(err, wsutil.ErrNoFrameAdvance) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
hdr, err2 := c.reader.NextFrame()
|
||||
if err2 != nil {
|
||||
return 0, err2
|
||||
}
|
||||
if hdr.OpCode.IsControl() {
|
||||
if err := c.controlHandler(hdr, c.reader); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if hdr.OpCode&(ws.OpBinary|ws.OpText) == 0 {
|
||||
if err := c.reader.Discard(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *wsStreamConn) Write(b []byte) (int, error) {
|
||||
if err := wsutil.WriteMessage(c.Conn, c.state, ws.OpBinary, b); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func (c *wsStreamConn) Close() error {
|
||||
_ = wsutil.WriteMessage(c.Conn, c.state, ws.OpClose, ws.NewCloseFrameBody(ws.StatusNormalClosure, ""))
|
||||
return c.Conn.Close()
|
||||
}
|
||||
93
transport/sudoku/obfs/sudoku/ascii_mode.go
Normal file
93
transport/sudoku/obfs/sudoku/ascii_mode.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
asciiModeTokenASCII = "ascii"
|
||||
asciiModeTokenEntropy = "entropy"
|
||||
)
|
||||
|
||||
// ASCIIMode describes the preferred wire layout for each traffic direction.
|
||||
// Uplink is client->server, Downlink is server->client.
|
||||
type ASCIIMode struct {
|
||||
Uplink string
|
||||
Downlink string
|
||||
}
|
||||
|
||||
// ParseASCIIMode accepts legacy symmetric values ("ascii"/"entropy"/"prefer_*")
|
||||
// and directional values like "up_ascii_down_entropy".
|
||||
func ParseASCIIMode(mode string) (ASCIIMode, error) {
|
||||
raw := strings.ToLower(strings.TrimSpace(mode))
|
||||
switch raw {
|
||||
case "", "entropy", "prefer_entropy":
|
||||
return ASCIIMode{Uplink: asciiModeTokenEntropy, Downlink: asciiModeTokenEntropy}, nil
|
||||
case "ascii", "prefer_ascii":
|
||||
return ASCIIMode{Uplink: asciiModeTokenASCII, Downlink: asciiModeTokenASCII}, nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(raw, "up_") {
|
||||
return ASCIIMode{}, fmt.Errorf("invalid ascii mode: %s", mode)
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimPrefix(raw, "up_"), "_down_", 2)
|
||||
if len(parts) != 2 {
|
||||
return ASCIIMode{}, fmt.Errorf("invalid ascii mode: %s", mode)
|
||||
}
|
||||
|
||||
up, ok := normalizeASCIIModeToken(parts[0])
|
||||
if !ok {
|
||||
return ASCIIMode{}, fmt.Errorf("invalid ascii mode: %s", mode)
|
||||
}
|
||||
down, ok := normalizeASCIIModeToken(parts[1])
|
||||
if !ok {
|
||||
return ASCIIMode{}, fmt.Errorf("invalid ascii mode: %s", mode)
|
||||
}
|
||||
return ASCIIMode{Uplink: up, Downlink: down}, nil
|
||||
}
|
||||
|
||||
// NormalizeASCIIMode returns the canonical config string for a supported mode.
|
||||
func NormalizeASCIIMode(mode string) (string, error) {
|
||||
parsed, err := ParseASCIIMode(mode)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parsed.Canonical(), nil
|
||||
}
|
||||
|
||||
func (m ASCIIMode) Canonical() string {
|
||||
if m.Uplink == asciiModeTokenASCII && m.Downlink == asciiModeTokenASCII {
|
||||
return "prefer_ascii"
|
||||
}
|
||||
if m.Uplink == asciiModeTokenEntropy && m.Downlink == asciiModeTokenEntropy {
|
||||
return "prefer_entropy"
|
||||
}
|
||||
return "up_" + m.Uplink + "_down_" + m.Downlink
|
||||
}
|
||||
|
||||
func (m ASCIIMode) uplinkPreference() string {
|
||||
return singleDirectionPreference(m.Uplink)
|
||||
}
|
||||
|
||||
func (m ASCIIMode) downlinkPreference() string {
|
||||
return singleDirectionPreference(m.Downlink)
|
||||
}
|
||||
|
||||
func normalizeASCIIModeToken(token string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(token)) {
|
||||
case "ascii", "prefer_ascii":
|
||||
return asciiModeTokenASCII, true
|
||||
case "entropy", "prefer_entropy", "":
|
||||
return asciiModeTokenEntropy, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func singleDirectionPreference(token string) string {
|
||||
if token == asciiModeTokenASCII {
|
||||
return "prefer_ascii"
|
||||
}
|
||||
return "prefer_entropy"
|
||||
}
|
||||
193
transport/sudoku/obfs/sudoku/conn.go
Normal file
193
transport/sudoku/obfs/sudoku/conn.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
const IOBufferSize = 32 * 1024
|
||||
|
||||
var perm4 = [24][4]byte{
|
||||
{0, 1, 2, 3},
|
||||
{0, 1, 3, 2},
|
||||
{0, 2, 1, 3},
|
||||
{0, 2, 3, 1},
|
||||
{0, 3, 1, 2},
|
||||
{0, 3, 2, 1},
|
||||
{1, 0, 2, 3},
|
||||
{1, 0, 3, 2},
|
||||
{1, 2, 0, 3},
|
||||
{1, 2, 3, 0},
|
||||
{1, 3, 0, 2},
|
||||
{1, 3, 2, 0},
|
||||
{2, 0, 1, 3},
|
||||
{2, 0, 3, 1},
|
||||
{2, 1, 0, 3},
|
||||
{2, 1, 3, 0},
|
||||
{2, 3, 0, 1},
|
||||
{2, 3, 1, 0},
|
||||
{3, 0, 1, 2},
|
||||
{3, 0, 2, 1},
|
||||
{3, 1, 0, 2},
|
||||
{3, 1, 2, 0},
|
||||
{3, 2, 0, 1},
|
||||
{3, 2, 1, 0},
|
||||
}
|
||||
|
||||
type Conn struct {
|
||||
net.Conn
|
||||
table *Table
|
||||
reader *bufio.Reader
|
||||
recorder *bytes.Buffer
|
||||
recording atomic.Bool
|
||||
recordLock sync.Mutex
|
||||
|
||||
rawBuf []byte
|
||||
pendingData pendingBuffer
|
||||
hintBuf [4]byte
|
||||
hintCount int
|
||||
writeMu sync.Mutex
|
||||
writeBuf []byte
|
||||
|
||||
rng randomSource
|
||||
paddingThreshold uint64
|
||||
}
|
||||
|
||||
func (sc *Conn) CloseWrite() error {
|
||||
if sc == nil || sc.Conn == nil {
|
||||
return nil
|
||||
}
|
||||
if cw, ok := sc.Conn.(interface{ CloseWrite() error }); ok {
|
||||
return cw.CloseWrite()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *Conn) CloseRead() error {
|
||||
if sc == nil || sc.Conn == nil {
|
||||
return nil
|
||||
}
|
||||
if cr, ok := sc.Conn.(interface{ CloseRead() error }); ok {
|
||||
return cr.CloseRead()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewConn(c net.Conn, table *Table, pMin, pMax int, record bool) *Conn {
|
||||
localRng := newSeededRand()
|
||||
|
||||
sc := &Conn{
|
||||
Conn: c,
|
||||
table: table,
|
||||
reader: bufio.NewReaderSize(c, IOBufferSize),
|
||||
rawBuf: make([]byte, IOBufferSize),
|
||||
pendingData: newPendingBuffer(4096),
|
||||
writeBuf: make([]byte, 0, 4096),
|
||||
rng: localRng,
|
||||
paddingThreshold: pickPaddingThreshold(localRng, pMin, pMax),
|
||||
}
|
||||
if record {
|
||||
sc.recorder = new(bytes.Buffer)
|
||||
sc.recording.Store(true)
|
||||
}
|
||||
return sc
|
||||
}
|
||||
|
||||
func (sc *Conn) StopRecording() {
|
||||
sc.recordLock.Lock()
|
||||
sc.recording.Store(false)
|
||||
sc.recorder = nil
|
||||
sc.recordLock.Unlock()
|
||||
}
|
||||
|
||||
func (sc *Conn) GetBufferedAndRecorded() []byte {
|
||||
if sc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sc.recordLock.Lock()
|
||||
defer sc.recordLock.Unlock()
|
||||
|
||||
var recorded []byte
|
||||
if sc.recorder != nil {
|
||||
recorded = sc.recorder.Bytes()
|
||||
}
|
||||
|
||||
buffered := sc.reader.Buffered()
|
||||
if buffered > 0 {
|
||||
peeked, _ := sc.reader.Peek(buffered)
|
||||
full := make([]byte, len(recorded)+len(peeked))
|
||||
copy(full, recorded)
|
||||
copy(full[len(recorded):], peeked)
|
||||
return full
|
||||
}
|
||||
return recorded
|
||||
}
|
||||
|
||||
func (sc *Conn) Write(p []byte) (n int, err error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
sc.writeMu.Lock()
|
||||
defer sc.writeMu.Unlock()
|
||||
|
||||
sc.writeBuf = encodeSudokuPayload(sc.writeBuf[:0], sc.table, sc.rng, sc.paddingThreshold, p)
|
||||
return len(p), writeFull(sc.Conn, sc.writeBuf)
|
||||
}
|
||||
|
||||
func (sc *Conn) Read(p []byte) (n int, err error) {
|
||||
if n, ok := drainPending(p, &sc.pendingData); ok {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
for {
|
||||
if sc.pendingData.available() > 0 {
|
||||
break
|
||||
}
|
||||
|
||||
nr, rErr := sc.reader.Read(sc.rawBuf)
|
||||
if nr > 0 {
|
||||
chunk := sc.rawBuf[:nr]
|
||||
if sc.recording.Load() {
|
||||
sc.recordLock.Lock()
|
||||
if sc.recording.Load() && sc.recorder != nil {
|
||||
sc.recorder.Write(chunk)
|
||||
}
|
||||
sc.recordLock.Unlock()
|
||||
}
|
||||
|
||||
layout := sc.table.layout
|
||||
for _, b := range chunk {
|
||||
if !layout.hintTable[b] {
|
||||
continue
|
||||
}
|
||||
|
||||
sc.hintBuf[sc.hintCount] = b
|
||||
sc.hintCount++
|
||||
if sc.hintCount == len(sc.hintBuf) {
|
||||
key := packHintsToKey(sc.hintBuf)
|
||||
val, ok := sc.table.DecodeMap[key]
|
||||
if !ok {
|
||||
return 0, ErrInvalidSudokuMapMiss
|
||||
}
|
||||
sc.pendingData.appendByte(val)
|
||||
sc.hintCount = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rErr != nil {
|
||||
return 0, rErr
|
||||
}
|
||||
if sc.pendingData.available() > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
n, _ = drainPending(p, &sc.pendingData)
|
||||
return n, nil
|
||||
}
|
||||
36
transport/sudoku/obfs/sudoku/encode.go
Normal file
36
transport/sudoku/obfs/sudoku/encode.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package sudoku
|
||||
|
||||
func encodeSudokuPayload(dst []byte, table *Table, rng randomSource, paddingThreshold uint64, p []byte) []byte {
|
||||
if len(p) == 0 {
|
||||
return dst[:0]
|
||||
}
|
||||
|
||||
outCapacity := len(p)*6 + 1
|
||||
if cap(dst) < outCapacity {
|
||||
dst = make([]byte, 0, outCapacity)
|
||||
}
|
||||
out := dst[:0]
|
||||
pads := table.PaddingPool
|
||||
padLen := len(pads)
|
||||
|
||||
for _, b := range p {
|
||||
if shouldPad(rng, paddingThreshold) {
|
||||
out = append(out, pads[rng.Intn(padLen)])
|
||||
}
|
||||
|
||||
puzzles := table.EncodeTable[b]
|
||||
puzzle := puzzles[rng.Intn(len(puzzles))]
|
||||
perm := perm4[rng.Intn(len(perm4))]
|
||||
for _, idx := range perm {
|
||||
if shouldPad(rng, paddingThreshold) {
|
||||
out = append(out, pads[rng.Intn(padLen)])
|
||||
}
|
||||
out = append(out, puzzle[idx])
|
||||
}
|
||||
}
|
||||
|
||||
if shouldPad(rng, paddingThreshold) {
|
||||
out = append(out, pads[rng.Intn(padLen)])
|
||||
}
|
||||
return out
|
||||
}
|
||||
46
transport/sudoku/obfs/sudoku/grid.go
Normal file
46
transport/sudoku/obfs/sudoku/grid.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package sudoku
|
||||
|
||||
// Grid represents a 4x4 sudoku grid
|
||||
type Grid [16]uint8
|
||||
|
||||
// GenerateAllGrids generates all valid 4x4 Sudoku grids
|
||||
func GenerateAllGrids() []Grid {
|
||||
var grids []Grid
|
||||
var g Grid
|
||||
var backtrack func(int)
|
||||
|
||||
backtrack = func(idx int) {
|
||||
if idx == 16 {
|
||||
grids = append(grids, g)
|
||||
return
|
||||
}
|
||||
row, col := idx/4, idx%4
|
||||
br, bc := (row/2)*2, (col/2)*2
|
||||
for num := uint8(1); num <= 4; num++ {
|
||||
valid := true
|
||||
for i := 0; i < 4; i++ {
|
||||
if g[row*4+i] == num || g[i*4+col] == num {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if valid {
|
||||
for r := 0; r < 2; r++ {
|
||||
for c := 0; c < 2; c++ {
|
||||
if g[(br+r)*4+(bc+c)] == num {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if valid {
|
||||
g[idx] = num
|
||||
backtrack(idx + 1)
|
||||
g[idx] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
backtrack(0)
|
||||
return grids
|
||||
}
|
||||
255
transport/sudoku/obfs/sudoku/layout.go
Normal file
255
transport/sudoku/obfs/sudoku/layout.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/bits"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type byteLayout struct {
|
||||
name string
|
||||
hintMask byte
|
||||
hintValue byte
|
||||
padMarker byte
|
||||
paddingPool []byte
|
||||
|
||||
hintTable [256]bool
|
||||
encodeHint [4][16]byte
|
||||
encodeGroup [64]byte
|
||||
decodeGroup [256]byte
|
||||
groupValid [256]bool
|
||||
}
|
||||
|
||||
func (l *byteLayout) isHint(b byte) bool {
|
||||
return l != nil && l.hintTable[b]
|
||||
}
|
||||
|
||||
func (l *byteLayout) hintByte(val, pos byte) byte {
|
||||
return l.encodeHint[val&0x03][pos&0x0F]
|
||||
}
|
||||
|
||||
func (l *byteLayout) groupByte(group byte) byte {
|
||||
return l.encodeGroup[group&0x3F]
|
||||
}
|
||||
|
||||
func (l *byteLayout) decodePackedGroup(b byte) (byte, bool) {
|
||||
if l == nil {
|
||||
return 0, false
|
||||
}
|
||||
return l.decodeGroup[b], l.groupValid[b]
|
||||
}
|
||||
|
||||
// resolveLayout picks the byte layout for a single traffic direction.
|
||||
// ASCII always wins if requested. Custom patterns are ignored when ASCII is preferred.
|
||||
func resolveLayout(mode string, customPattern string) (*byteLayout, error) {
|
||||
switch strings.ToLower(mode) {
|
||||
case "ascii", "prefer_ascii":
|
||||
return newASCIILayout(), nil
|
||||
case "entropy", "prefer_entropy", "":
|
||||
// fallback to entropy unless a custom pattern is provided
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid ascii mode: %s", mode)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(customPattern) != "" {
|
||||
return newCustomLayout(customPattern)
|
||||
}
|
||||
return newEntropyLayout(), nil
|
||||
}
|
||||
|
||||
func newASCIILayout() *byteLayout {
|
||||
padding := make([]byte, 0, 32)
|
||||
for i := 0; i < 32; i++ {
|
||||
padding = append(padding, byte(0x20+i))
|
||||
}
|
||||
|
||||
layout := &byteLayout{
|
||||
name: "ascii",
|
||||
hintMask: 0x40,
|
||||
hintValue: 0x40,
|
||||
padMarker: 0x3F,
|
||||
paddingPool: padding,
|
||||
}
|
||||
|
||||
for val := 0; val < 4; val++ {
|
||||
for pos := 0; pos < 16; pos++ {
|
||||
b := byte(0x40 | (byte(val) << 4) | byte(pos))
|
||||
if b == 0x7F {
|
||||
b = '\n'
|
||||
}
|
||||
layout.encodeHint[val][pos] = b
|
||||
}
|
||||
}
|
||||
for group := 0; group < 64; group++ {
|
||||
b := byte(0x40 | byte(group))
|
||||
if b == 0x7F {
|
||||
b = '\n'
|
||||
}
|
||||
layout.encodeGroup[group] = b
|
||||
}
|
||||
for b := 0; b < 256; b++ {
|
||||
wire := byte(b)
|
||||
if (wire & 0x40) == 0x40 {
|
||||
layout.hintTable[wire] = true
|
||||
layout.decodeGroup[wire] = wire & 0x3F
|
||||
layout.groupValid[wire] = true
|
||||
}
|
||||
}
|
||||
layout.hintTable['\n'] = true
|
||||
layout.decodeGroup['\n'] = 0x3F
|
||||
layout.groupValid['\n'] = true
|
||||
|
||||
return layout
|
||||
}
|
||||
|
||||
func newEntropyLayout() *byteLayout {
|
||||
padding := make([]byte, 0, 16)
|
||||
for i := 0; i < 8; i++ {
|
||||
padding = append(padding, byte(0x80+i))
|
||||
padding = append(padding, byte(0x10+i))
|
||||
}
|
||||
|
||||
layout := &byteLayout{
|
||||
name: "entropy",
|
||||
hintMask: 0x90,
|
||||
hintValue: 0x00,
|
||||
padMarker: 0x80,
|
||||
paddingPool: padding,
|
||||
}
|
||||
|
||||
for val := 0; val < 4; val++ {
|
||||
for pos := 0; pos < 16; pos++ {
|
||||
layout.encodeHint[val][pos] = (byte(val) << 5) | byte(pos)
|
||||
}
|
||||
}
|
||||
for group := 0; group < 64; group++ {
|
||||
v := byte(group)
|
||||
layout.encodeGroup[group] = ((v & 0x30) << 1) | (v & 0x0F)
|
||||
}
|
||||
for b := 0; b < 256; b++ {
|
||||
wire := byte(b)
|
||||
if (wire & 0x90) != 0 {
|
||||
continue
|
||||
}
|
||||
layout.hintTable[wire] = true
|
||||
layout.decodeGroup[wire] = ((wire >> 1) & 0x30) | (wire & 0x0F)
|
||||
layout.groupValid[wire] = true
|
||||
}
|
||||
|
||||
return layout
|
||||
}
|
||||
|
||||
func newCustomLayout(pattern string) (*byteLayout, error) {
|
||||
cleaned := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(pattern), " ", ""))
|
||||
if len(cleaned) != 8 {
|
||||
return nil, fmt.Errorf("custom table must have 8 symbols, got %d", len(cleaned))
|
||||
}
|
||||
|
||||
var xBits, pBits, vBits []uint8
|
||||
for i, c := range cleaned {
|
||||
bit := uint8(7 - i)
|
||||
switch c {
|
||||
case 'x':
|
||||
xBits = append(xBits, bit)
|
||||
case 'p':
|
||||
pBits = append(pBits, bit)
|
||||
case 'v':
|
||||
vBits = append(vBits, bit)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid char %q in custom table", c)
|
||||
}
|
||||
}
|
||||
|
||||
if len(xBits) != 2 || len(pBits) != 2 || len(vBits) != 4 {
|
||||
return nil, fmt.Errorf("custom table must contain exactly 2 x, 2 p, 4 v")
|
||||
}
|
||||
|
||||
xMask := byte(0)
|
||||
for _, b := range xBits {
|
||||
xMask |= 1 << b
|
||||
}
|
||||
|
||||
encodeBits := func(val, pos byte, dropX int) byte {
|
||||
var out byte
|
||||
out |= xMask
|
||||
if dropX >= 0 {
|
||||
out &^= 1 << xBits[dropX]
|
||||
}
|
||||
if (val & 0x02) != 0 {
|
||||
out |= 1 << pBits[0]
|
||||
}
|
||||
if (val & 0x01) != 0 {
|
||||
out |= 1 << pBits[1]
|
||||
}
|
||||
for i, bit := range vBits {
|
||||
if (pos>>(3-uint8(i)))&0x01 == 1 {
|
||||
out |= 1 << bit
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
paddingSet := make(map[byte]struct{})
|
||||
var padding []byte
|
||||
for drop := range xBits {
|
||||
for val := 0; val < 4; val++ {
|
||||
for pos := 0; pos < 16; pos++ {
|
||||
b := encodeBits(byte(val), byte(pos), drop)
|
||||
if bits.OnesCount8(b) >= 5 {
|
||||
if _, ok := paddingSet[b]; !ok {
|
||||
paddingSet[b] = struct{}{}
|
||||
padding = append(padding, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(padding, func(i, j int) bool { return padding[i] < padding[j] })
|
||||
if len(padding) == 0 {
|
||||
return nil, fmt.Errorf("custom table produced empty padding pool")
|
||||
}
|
||||
|
||||
layout := &byteLayout{
|
||||
name: fmt.Sprintf("custom(%s)", cleaned),
|
||||
hintMask: xMask,
|
||||
hintValue: xMask,
|
||||
padMarker: padding[0],
|
||||
paddingPool: padding,
|
||||
}
|
||||
|
||||
for val := 0; val < 4; val++ {
|
||||
for pos := 0; pos < 16; pos++ {
|
||||
layout.encodeHint[val][pos] = encodeBits(byte(val), byte(pos), -1)
|
||||
}
|
||||
}
|
||||
for group := 0; group < 64; group++ {
|
||||
val := byte(group>>4) & 0x03
|
||||
pos := byte(group) & 0x0F
|
||||
layout.encodeGroup[group] = encodeBits(val, pos, -1)
|
||||
}
|
||||
for b := 0; b < 256; b++ {
|
||||
wire := byte(b)
|
||||
if (wire & xMask) != xMask {
|
||||
continue
|
||||
}
|
||||
layout.hintTable[wire] = true
|
||||
|
||||
var val, pos byte
|
||||
if wire&(1<<pBits[0]) != 0 {
|
||||
val |= 0x02
|
||||
}
|
||||
if wire&(1<<pBits[1]) != 0 {
|
||||
val |= 0x01
|
||||
}
|
||||
for i, bit := range vBits {
|
||||
if wire&(1<<bit) != 0 {
|
||||
pos |= 1 << (3 - uint8(i))
|
||||
}
|
||||
}
|
||||
layout.decodeGroup[wire] = (val << 4) | pos
|
||||
layout.groupValid[wire] = true
|
||||
}
|
||||
|
||||
return layout, nil
|
||||
}
|
||||
359
transport/sudoku/obfs/sudoku/packed.go
Normal file
359
transport/sudoku/obfs/sudoku/packed.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
RngBatchSize = 128
|
||||
|
||||
packedProtectedPrefixBytes = 14
|
||||
)
|
||||
|
||||
// PackedConn encodes traffic with the packed Sudoku layout while preserving
|
||||
// the same padding model as the regular connection.
|
||||
type PackedConn struct {
|
||||
net.Conn
|
||||
table *Table
|
||||
reader *bufio.Reader
|
||||
|
||||
// Read-side buffers.
|
||||
rawBuf []byte
|
||||
pendingData pendingBuffer
|
||||
|
||||
// Write-side state.
|
||||
writeMu sync.Mutex
|
||||
writeBuf []byte
|
||||
bitBuf uint64
|
||||
bitCount int
|
||||
|
||||
// Read-side bit accumulator.
|
||||
readBitBuf uint64
|
||||
readBits int
|
||||
|
||||
// Padding selection matches Conn's threshold-based model.
|
||||
rng randomSource
|
||||
paddingThreshold uint64
|
||||
padMarker byte
|
||||
padPool []byte
|
||||
}
|
||||
|
||||
func (pc *PackedConn) CloseWrite() error {
|
||||
if pc == nil || pc.Conn == nil {
|
||||
return nil
|
||||
}
|
||||
if cw, ok := pc.Conn.(interface{ CloseWrite() error }); ok {
|
||||
return cw.CloseWrite()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pc *PackedConn) CloseRead() error {
|
||||
if pc == nil || pc.Conn == nil {
|
||||
return nil
|
||||
}
|
||||
if cr, ok := pc.Conn.(interface{ CloseRead() error }); ok {
|
||||
return cr.CloseRead()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewPackedConn(c net.Conn, table *Table, pMin, pMax int) *PackedConn {
|
||||
localRng := newSeededRand()
|
||||
|
||||
pc := &PackedConn{
|
||||
Conn: c,
|
||||
table: table,
|
||||
reader: bufio.NewReaderSize(c, IOBufferSize),
|
||||
rawBuf: make([]byte, IOBufferSize),
|
||||
pendingData: newPendingBuffer(4096),
|
||||
writeBuf: make([]byte, 0, 4096),
|
||||
rng: localRng,
|
||||
paddingThreshold: pickPaddingThreshold(localRng, pMin, pMax),
|
||||
}
|
||||
|
||||
pc.padMarker = table.layout.padMarker
|
||||
for _, b := range table.PaddingPool {
|
||||
if b != pc.padMarker {
|
||||
pc.padPool = append(pc.padPool, b)
|
||||
}
|
||||
}
|
||||
if len(pc.padPool) == 0 {
|
||||
pc.padPool = append(pc.padPool, pc.padMarker)
|
||||
}
|
||||
return pc
|
||||
}
|
||||
|
||||
func (pc *PackedConn) maybeAddPadding(out []byte) []byte {
|
||||
if shouldPad(pc.rng, pc.paddingThreshold) {
|
||||
out = append(out, pc.getPaddingByte())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (pc *PackedConn) appendGroup(out []byte, group byte) []byte {
|
||||
out = pc.maybeAddPadding(out)
|
||||
return append(out, pc.table.layout.groupByte(group))
|
||||
}
|
||||
|
||||
func (pc *PackedConn) appendForcedPadding(out []byte) []byte {
|
||||
return append(out, pc.getPaddingByte())
|
||||
}
|
||||
|
||||
func (pc *PackedConn) nextProtectedPrefixGap() int {
|
||||
return 1 + pc.rng.Intn(2)
|
||||
}
|
||||
|
||||
func (pc *PackedConn) writeProtectedPrefix(out []byte, p []byte) ([]byte, int) {
|
||||
if len(p) == 0 {
|
||||
return out, 0
|
||||
}
|
||||
|
||||
limit := len(p)
|
||||
if limit > packedProtectedPrefixBytes {
|
||||
limit = packedProtectedPrefixBytes
|
||||
}
|
||||
|
||||
for padCount := 0; padCount < 1+pc.rng.Intn(2); padCount++ {
|
||||
out = pc.appendForcedPadding(out)
|
||||
}
|
||||
|
||||
gap := pc.nextProtectedPrefixGap()
|
||||
effective := 0
|
||||
for i := 0; i < limit; i++ {
|
||||
pc.bitBuf = (pc.bitBuf << 8) | uint64(p[i])
|
||||
pc.bitCount += 8
|
||||
for pc.bitCount >= 6 {
|
||||
pc.bitCount -= 6
|
||||
group := byte(pc.bitBuf >> pc.bitCount)
|
||||
if pc.bitCount == 0 {
|
||||
pc.bitBuf = 0
|
||||
} else {
|
||||
pc.bitBuf &= (1 << pc.bitCount) - 1
|
||||
}
|
||||
out = pc.appendGroup(out, group&0x3F)
|
||||
}
|
||||
|
||||
effective++
|
||||
if effective >= gap {
|
||||
out = pc.appendForcedPadding(out)
|
||||
effective = 0
|
||||
gap = pc.nextProtectedPrefixGap()
|
||||
}
|
||||
}
|
||||
|
||||
return out, limit
|
||||
}
|
||||
|
||||
func (pc *PackedConn) Write(p []byte) (int, error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
pc.writeMu.Lock()
|
||||
defer pc.writeMu.Unlock()
|
||||
|
||||
needed := len(p)*3/2 + 32
|
||||
if cap(pc.writeBuf) < needed {
|
||||
pc.writeBuf = make([]byte, 0, needed)
|
||||
}
|
||||
out := pc.writeBuf[:0]
|
||||
|
||||
var prefixN int
|
||||
out, prefixN = pc.writeProtectedPrefix(out, p)
|
||||
|
||||
i := prefixN
|
||||
n := len(p)
|
||||
|
||||
for pc.bitCount > 0 && i < n {
|
||||
b := p[i]
|
||||
i++
|
||||
pc.bitBuf = (pc.bitBuf << 8) | uint64(b)
|
||||
pc.bitCount += 8
|
||||
for pc.bitCount >= 6 {
|
||||
pc.bitCount -= 6
|
||||
group := byte(pc.bitBuf >> pc.bitCount)
|
||||
if pc.bitCount == 0 {
|
||||
pc.bitBuf = 0
|
||||
} else {
|
||||
pc.bitBuf &= (1 << pc.bitCount) - 1
|
||||
}
|
||||
out = pc.appendGroup(out, group&0x3F)
|
||||
}
|
||||
}
|
||||
|
||||
for i+11 < n {
|
||||
for batch := 0; batch < 4; batch++ {
|
||||
b1, b2, b3 := p[i], p[i+1], p[i+2]
|
||||
i += 3
|
||||
|
||||
g1 := (b1 >> 2) & 0x3F
|
||||
g2 := ((b1 & 0x03) << 4) | ((b2 >> 4) & 0x0F)
|
||||
g3 := ((b2 & 0x0F) << 2) | ((b3 >> 6) & 0x03)
|
||||
g4 := b3 & 0x3F
|
||||
|
||||
out = pc.appendGroup(out, g1)
|
||||
out = pc.appendGroup(out, g2)
|
||||
out = pc.appendGroup(out, g3)
|
||||
out = pc.appendGroup(out, g4)
|
||||
}
|
||||
}
|
||||
|
||||
for i+2 < n {
|
||||
b1, b2, b3 := p[i], p[i+1], p[i+2]
|
||||
i += 3
|
||||
|
||||
g1 := (b1 >> 2) & 0x3F
|
||||
g2 := ((b1 & 0x03) << 4) | ((b2 >> 4) & 0x0F)
|
||||
g3 := ((b2 & 0x0F) << 2) | ((b3 >> 6) & 0x03)
|
||||
g4 := b3 & 0x3F
|
||||
|
||||
out = pc.appendGroup(out, g1)
|
||||
out = pc.appendGroup(out, g2)
|
||||
out = pc.appendGroup(out, g3)
|
||||
out = pc.appendGroup(out, g4)
|
||||
}
|
||||
|
||||
for ; i < n; i++ {
|
||||
b := p[i]
|
||||
pc.bitBuf = (pc.bitBuf << 8) | uint64(b)
|
||||
pc.bitCount += 8
|
||||
for pc.bitCount >= 6 {
|
||||
pc.bitCount -= 6
|
||||
group := byte(pc.bitBuf >> pc.bitCount)
|
||||
if pc.bitCount == 0 {
|
||||
pc.bitBuf = 0
|
||||
} else {
|
||||
pc.bitBuf &= (1 << pc.bitCount) - 1
|
||||
}
|
||||
out = pc.appendGroup(out, group&0x3F)
|
||||
}
|
||||
}
|
||||
|
||||
if pc.bitCount > 0 {
|
||||
group := byte(pc.bitBuf << (6 - pc.bitCount))
|
||||
pc.bitBuf = 0
|
||||
pc.bitCount = 0
|
||||
out = pc.appendGroup(out, group&0x3F)
|
||||
out = append(out, pc.padMarker)
|
||||
}
|
||||
|
||||
out = pc.maybeAddPadding(out)
|
||||
|
||||
if len(out) > 0 {
|
||||
pc.writeBuf = out[:0]
|
||||
return len(p), writeFull(pc.Conn, out)
|
||||
}
|
||||
pc.writeBuf = out[:0]
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (pc *PackedConn) Flush() error {
|
||||
pc.writeMu.Lock()
|
||||
defer pc.writeMu.Unlock()
|
||||
|
||||
out := pc.writeBuf[:0]
|
||||
if pc.bitCount > 0 {
|
||||
group := byte(pc.bitBuf << (6 - pc.bitCount))
|
||||
pc.bitBuf = 0
|
||||
pc.bitCount = 0
|
||||
|
||||
out = append(out, pc.table.layout.groupByte(group&0x3F))
|
||||
out = append(out, pc.padMarker)
|
||||
}
|
||||
|
||||
out = pc.maybeAddPadding(out)
|
||||
|
||||
if len(out) > 0 {
|
||||
pc.writeBuf = out[:0]
|
||||
return writeFull(pc.Conn, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeFull(w io.Writer, b []byte) error {
|
||||
for len(b) > 0 {
|
||||
n, err := w.Write(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
b = b[n:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pc *PackedConn) Read(p []byte) (int, error) {
|
||||
if n, ok := drainPending(p, &pc.pendingData); ok {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
for {
|
||||
nr, rErr := pc.reader.Read(pc.rawBuf)
|
||||
if nr > 0 {
|
||||
rBuf := pc.readBitBuf
|
||||
rBits := pc.readBits
|
||||
padMarker := pc.padMarker
|
||||
layout := pc.table.layout
|
||||
|
||||
for _, b := range pc.rawBuf[:nr] {
|
||||
if !layout.hintTable[b] {
|
||||
if b == padMarker {
|
||||
rBuf = 0
|
||||
rBits = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
group, ok := layout.decodePackedGroup(b)
|
||||
if !ok {
|
||||
return 0, ErrInvalidSudokuMapMiss
|
||||
}
|
||||
|
||||
rBuf = (rBuf << 6) | uint64(group)
|
||||
rBits += 6
|
||||
|
||||
if rBits >= 8 {
|
||||
rBits -= 8
|
||||
val := byte(rBuf >> rBits)
|
||||
pc.pendingData.appendByte(val)
|
||||
if rBits == 0 {
|
||||
rBuf = 0
|
||||
} else {
|
||||
rBuf &= (uint64(1) << rBits) - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pc.readBitBuf = rBuf
|
||||
pc.readBits = rBits
|
||||
}
|
||||
|
||||
if rErr != nil {
|
||||
if rErr == io.EOF {
|
||||
pc.readBitBuf = 0
|
||||
pc.readBits = 0
|
||||
}
|
||||
if pc.pendingData.available() > 0 {
|
||||
break
|
||||
}
|
||||
return 0, rErr
|
||||
}
|
||||
|
||||
if pc.pendingData.available() > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
n, _ := drainPending(p, &pc.pendingData)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (pc *PackedConn) getPaddingByte() byte {
|
||||
return pc.padPool[pc.rng.Intn(len(pc.padPool))]
|
||||
}
|
||||
42
transport/sudoku/obfs/sudoku/padding_prob.go
Normal file
42
transport/sudoku/obfs/sudoku/padding_prob.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package sudoku
|
||||
|
||||
const probOne = uint64(1) << 32
|
||||
|
||||
func pickPaddingThreshold(r randomSource, pMin, pMax int) uint64 {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
if pMin < 0 {
|
||||
pMin = 0
|
||||
}
|
||||
if pMax < pMin {
|
||||
pMax = pMin
|
||||
}
|
||||
if pMax > 100 {
|
||||
pMax = 100
|
||||
}
|
||||
if pMin > 100 {
|
||||
pMin = 100
|
||||
}
|
||||
|
||||
min := uint64(pMin) * probOne / 100
|
||||
max := uint64(pMax) * probOne / 100
|
||||
if max <= min {
|
||||
return min
|
||||
}
|
||||
u := uint64(r.Uint32())
|
||||
return min + (u * (max - min) >> 32)
|
||||
}
|
||||
|
||||
func shouldPad(r randomSource, threshold uint64) bool {
|
||||
if threshold == 0 {
|
||||
return false
|
||||
}
|
||||
if threshold >= probOne {
|
||||
return true
|
||||
}
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
return uint64(r.Uint32()) < threshold
|
||||
}
|
||||
57
transport/sudoku/obfs/sudoku/pending.go
Normal file
57
transport/sudoku/obfs/sudoku/pending.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package sudoku
|
||||
|
||||
type pendingBuffer struct {
|
||||
data []byte
|
||||
off int
|
||||
}
|
||||
|
||||
func newPendingBuffer(capacity int) pendingBuffer {
|
||||
return pendingBuffer{data: make([]byte, 0, capacity)}
|
||||
}
|
||||
|
||||
func (p *pendingBuffer) available() int {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return len(p.data) - p.off
|
||||
}
|
||||
|
||||
func (p *pendingBuffer) reset() {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.data = p.data[:0]
|
||||
p.off = 0
|
||||
}
|
||||
|
||||
func (p *pendingBuffer) ensureAppendCapacity(extra int) {
|
||||
if p == nil || extra <= 0 || p.off == 0 {
|
||||
return
|
||||
}
|
||||
if cap(p.data)-len(p.data) >= extra {
|
||||
return
|
||||
}
|
||||
|
||||
unread := len(p.data) - p.off
|
||||
copy(p.data[:unread], p.data[p.off:])
|
||||
p.data = p.data[:unread]
|
||||
p.off = 0
|
||||
}
|
||||
|
||||
func (p *pendingBuffer) appendByte(b byte) {
|
||||
p.ensureAppendCapacity(1)
|
||||
p.data = append(p.data, b)
|
||||
}
|
||||
|
||||
func drainPending(dst []byte, pending *pendingBuffer) (int, bool) {
|
||||
if pending == nil || pending.available() == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
n := copy(dst, pending.data[pending.off:])
|
||||
pending.off += n
|
||||
if pending.off == len(pending.data) {
|
||||
pending.reset()
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
56
transport/sudoku/obfs/sudoku/rand.go
Normal file
56
transport/sudoku/obfs/sudoku/rand.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
crypto_rand "crypto/rand"
|
||||
"encoding/binary"
|
||||
"time"
|
||||
)
|
||||
|
||||
type randomSource interface {
|
||||
Uint32() uint32
|
||||
Uint64() uint64
|
||||
Intn(n int) int
|
||||
}
|
||||
|
||||
type sudokuRand struct {
|
||||
state uint64
|
||||
}
|
||||
|
||||
func newSeededRand() *sudokuRand {
|
||||
seed := time.Now().UnixNano()
|
||||
var seedBytes [8]byte
|
||||
if _, err := crypto_rand.Read(seedBytes[:]); err == nil {
|
||||
seed = int64(binary.BigEndian.Uint64(seedBytes[:]))
|
||||
}
|
||||
return newSudokuRand(seed)
|
||||
}
|
||||
|
||||
func newSudokuRand(seed int64) *sudokuRand {
|
||||
state := uint64(seed)
|
||||
if state == 0 {
|
||||
state = 0x9e3779b97f4a7c15
|
||||
}
|
||||
return &sudokuRand{state: state}
|
||||
}
|
||||
|
||||
func (r *sudokuRand) Uint64() uint64 {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
r.state += 0x9e3779b97f4a7c15
|
||||
z := r.state
|
||||
z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9
|
||||
z = (z ^ (z >> 27)) * 0x94d049bb133111eb
|
||||
return z ^ (z >> 31)
|
||||
}
|
||||
|
||||
func (r *sudokuRand) Uint32() uint32 {
|
||||
return uint32(r.Uint64() >> 32)
|
||||
}
|
||||
|
||||
func (r *sudokuRand) Intn(n int) int {
|
||||
if n <= 1 {
|
||||
return 0
|
||||
}
|
||||
return int((uint64(r.Uint32()) * uint64(n)) >> 32)
|
||||
}
|
||||
214
transport/sudoku/obfs/sudoku/table.go
Normal file
214
transport/sudoku/obfs/sudoku/table.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"math/rand"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidSudokuMapMiss = errors.New("INVALID_SUDOKU_MAP_MISS")
|
||||
)
|
||||
|
||||
type Table struct {
|
||||
EncodeTable [256][][4]byte
|
||||
DecodeMap map[uint32]byte
|
||||
PaddingPool []byte
|
||||
IsASCII bool // 标记当前模式
|
||||
layout *byteLayout
|
||||
opposite *Table
|
||||
hint uint32
|
||||
}
|
||||
|
||||
// NewTable initializes the obfuscation tables with built-in layouts.
|
||||
// Equivalent to calling NewTableWithCustom(key, mode, "").
|
||||
func NewTable(key string, mode string) *Table {
|
||||
t, err := NewTableWithCustom(key, mode, "")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// NewTableWithCustom initializes the uplink/probe Sudoku table using either predefined
|
||||
// or directional layouts. Directional modes such as "up_ascii_down_entropy" return the
|
||||
// client->server table and internally attach the opposite direction table for runtime use.
|
||||
// The customPattern must contain 8 characters with exactly 2 x, 2 p, and 4 v (case-insensitive).
|
||||
func NewTableWithCustom(key string, mode string, customPattern string) (*Table, error) {
|
||||
asciiMode, err := ParseASCIIMode(mode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uplinkPattern := customPatternForToken(asciiMode.Uplink, customPattern)
|
||||
downlinkPattern := customPatternForToken(asciiMode.Downlink, customPattern)
|
||||
hint := tableHintFingerprint(key, asciiMode.Canonical(), uplinkPattern, downlinkPattern)
|
||||
|
||||
uplink, err := newSingleDirectionTable(key, asciiMode.uplinkPreference(), uplinkPattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uplink.hint = hint
|
||||
if asciiMode.Uplink == asciiMode.Downlink {
|
||||
uplink.opposite = uplink
|
||||
return uplink, nil
|
||||
}
|
||||
|
||||
downlink, err := newSingleDirectionTable(key, asciiMode.downlinkPreference(), downlinkPattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
downlink.hint = hint
|
||||
uplink.opposite = downlink
|
||||
downlink.opposite = uplink
|
||||
return uplink, nil
|
||||
}
|
||||
|
||||
func newSingleDirectionTable(key string, mode string, customPattern string) (*Table, error) {
|
||||
layout, err := resolveLayout(mode, customPattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t := &Table{
|
||||
DecodeMap: make(map[uint32]byte),
|
||||
IsASCII: layout.name == "ascii",
|
||||
layout: layout,
|
||||
}
|
||||
t.PaddingPool = append(t.PaddingPool, layout.paddingPool...)
|
||||
|
||||
// 生成数独网格 (逻辑不变)
|
||||
allGrids := GenerateAllGrids()
|
||||
h := sha256.New()
|
||||
h.Write([]byte(key))
|
||||
seed := int64(binary.BigEndian.Uint64(h.Sum(nil)[:8]))
|
||||
rng := rand.New(rand.NewSource(seed))
|
||||
|
||||
shuffledGrids := make([]Grid, 288)
|
||||
copy(shuffledGrids, allGrids)
|
||||
rng.Shuffle(len(shuffledGrids), func(i, j int) {
|
||||
shuffledGrids[i], shuffledGrids[j] = shuffledGrids[j], shuffledGrids[i]
|
||||
})
|
||||
|
||||
// 预计算组合
|
||||
var combinations [][]int
|
||||
var combine func(int, int, []int)
|
||||
combine = func(s, k int, c []int) {
|
||||
if k == 0 {
|
||||
tmp := make([]int, len(c))
|
||||
copy(tmp, c)
|
||||
combinations = append(combinations, tmp)
|
||||
return
|
||||
}
|
||||
for i := s; i <= 16-k; i++ {
|
||||
c = append(c, i)
|
||||
combine(i+1, k-1, c)
|
||||
c = c[:len(c)-1]
|
||||
}
|
||||
}
|
||||
combine(0, 4, []int{})
|
||||
|
||||
// 构建映射表
|
||||
for byteVal := 0; byteVal < 256; byteVal++ {
|
||||
targetGrid := shuffledGrids[byteVal]
|
||||
for _, positions := range combinations {
|
||||
var currentHints [4]byte
|
||||
|
||||
// 1. 计算抽象提示 (Abstract Hints)
|
||||
// 我们先计算出 val 和 pos,后面再根据模式编码成 byte
|
||||
var rawParts [4]struct{ val, pos byte }
|
||||
|
||||
for i, pos := range positions {
|
||||
val := targetGrid[pos] // 1..4
|
||||
rawParts[i] = struct{ val, pos byte }{val, uint8(pos)}
|
||||
}
|
||||
|
||||
// 检查唯一性 (数独逻辑)
|
||||
matchCount := 0
|
||||
for _, g := range allGrids {
|
||||
match := true
|
||||
for _, p := range rawParts {
|
||||
if g[p.pos] != p.val {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
matchCount++
|
||||
if matchCount > 1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matchCount == 1 {
|
||||
// 唯一确定,生成最终编码字节
|
||||
for i, p := range rawParts {
|
||||
currentHints[i] = t.layout.hintByte(p.val-1, p.pos)
|
||||
}
|
||||
|
||||
t.EncodeTable[byteVal] = append(t.EncodeTable[byteVal], currentHints)
|
||||
// 生成解码键 (需要对 Hints 进行排序以忽略传输顺序)
|
||||
key := packHintsToKey(currentHints)
|
||||
t.DecodeMap[key] = byte(byteVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func customPatternForToken(token string, customPattern string) string {
|
||||
if token == asciiModeTokenEntropy {
|
||||
return customPattern
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (t *Table) OppositeDirection() *Table {
|
||||
if t == nil || t.opposite == nil {
|
||||
return t
|
||||
}
|
||||
return t.opposite
|
||||
}
|
||||
|
||||
func (t *Table) Hint() uint32 {
|
||||
if t == nil {
|
||||
return 0
|
||||
}
|
||||
return t.hint
|
||||
}
|
||||
|
||||
func tableHintFingerprint(key string, mode string, uplinkPattern string, downlinkPattern string) uint32 {
|
||||
sum := sha256.Sum256([]byte(strings.Join([]string{
|
||||
"sudoku-table-hint",
|
||||
key,
|
||||
mode,
|
||||
strings.ToLower(strings.TrimSpace(uplinkPattern)),
|
||||
strings.ToLower(strings.TrimSpace(downlinkPattern)),
|
||||
}, "\x00")))
|
||||
return binary.BigEndian.Uint32(sum[:4])
|
||||
}
|
||||
|
||||
func packHintsToKey(hints [4]byte) uint32 {
|
||||
// Sorting network for 4 elements (Bubble sort unrolled)
|
||||
// Swap if a > b
|
||||
if hints[0] > hints[1] {
|
||||
hints[0], hints[1] = hints[1], hints[0]
|
||||
}
|
||||
if hints[2] > hints[3] {
|
||||
hints[2], hints[3] = hints[3], hints[2]
|
||||
}
|
||||
if hints[0] > hints[2] {
|
||||
hints[0], hints[2] = hints[2], hints[0]
|
||||
}
|
||||
if hints[1] > hints[3] {
|
||||
hints[1], hints[3] = hints[3], hints[1]
|
||||
}
|
||||
if hints[1] > hints[2] {
|
||||
hints[1], hints[2] = hints[2], hints[1]
|
||||
}
|
||||
|
||||
return uint32(hints[0])<<24 | uint32(hints[1])<<16 | uint32(hints[2])<<8 | uint32(hints[3])
|
||||
}
|
||||
38
transport/sudoku/obfs/sudoku/table_set.go
Normal file
38
transport/sudoku/obfs/sudoku/table_set.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package sudoku
|
||||
|
||||
import "fmt"
|
||||
|
||||
// TableSet is a small helper for managing multiple Sudoku tables (e.g. for per-connection rotation).
|
||||
// It is intentionally decoupled from the tunnel/app layers.
|
||||
type TableSet struct {
|
||||
Tables []*Table
|
||||
}
|
||||
|
||||
// NewTableSet builds one or more tables from key/mode and a list of custom X/P/V patterns.
|
||||
// If patterns is empty, it builds a single default table (customPattern="").
|
||||
func NewTableSet(key string, mode string, patterns []string) (*TableSet, error) {
|
||||
if len(patterns) == 0 {
|
||||
t, err := NewTableWithCustom(key, mode, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TableSet{Tables: []*Table{t}}, nil
|
||||
}
|
||||
|
||||
tables := make([]*Table, 0, len(patterns))
|
||||
for i, pattern := range patterns {
|
||||
t, err := NewTableWithCustom(key, mode, pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build table[%d] (%q): %w", i, pattern, err)
|
||||
}
|
||||
tables = append(tables, t)
|
||||
}
|
||||
return &TableSet{Tables: tables}, nil
|
||||
}
|
||||
|
||||
func (ts *TableSet) Candidates() []*Table {
|
||||
if ts == nil {
|
||||
return nil
|
||||
}
|
||||
return ts.Tables
|
||||
}
|
||||
74
transport/sudoku/replay.go
Normal file
74
transport/sudoku/replay.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var handshakeReplayTTL = 60 * time.Second
|
||||
|
||||
type nonceSet struct {
|
||||
mu sync.Mutex
|
||||
m map[[kipHelloNonceSize]byte]time.Time
|
||||
maxEntries int
|
||||
lastPrune time.Time
|
||||
}
|
||||
|
||||
func newNonceSet(maxEntries int) *nonceSet {
|
||||
if maxEntries <= 0 {
|
||||
maxEntries = 4096
|
||||
}
|
||||
return &nonceSet{
|
||||
m: make(map[[kipHelloNonceSize]byte]time.Time),
|
||||
maxEntries: maxEntries,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *nonceSet) allow(nonce [kipHelloNonceSize]byte, now time.Time, ttl time.Duration) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if ttl <= 0 {
|
||||
ttl = 60 * time.Second
|
||||
}
|
||||
|
||||
if now.Sub(s.lastPrune) > ttl/2 || len(s.m) > s.maxEntries {
|
||||
for k, exp := range s.m {
|
||||
if !now.Before(exp) {
|
||||
delete(s.m, k)
|
||||
}
|
||||
}
|
||||
s.lastPrune = now
|
||||
for len(s.m) > s.maxEntries {
|
||||
for k := range s.m {
|
||||
delete(s.m, k)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if exp, ok := s.m[nonce]; ok && now.Before(exp) {
|
||||
return false
|
||||
}
|
||||
s.m[nonce] = now.Add(ttl)
|
||||
return true
|
||||
}
|
||||
|
||||
type handshakeReplayProtector struct {
|
||||
users sync.Map // map[userHash string]*nonceSet
|
||||
}
|
||||
|
||||
func (p *handshakeReplayProtector) allow(userHash string, nonce [kipHelloNonceSize]byte, now time.Time) bool {
|
||||
if userHash == "" {
|
||||
userHash = "_"
|
||||
}
|
||||
val, _ := p.users.LoadOrStore(userHash, newNonceSet(4096))
|
||||
set, ok := val.(*nonceSet)
|
||||
if !ok || set == nil {
|
||||
set = newNonceSet(4096)
|
||||
p.users.Store(userHash, set)
|
||||
}
|
||||
return set.allow(nonce, now, handshakeReplayTTL)
|
||||
}
|
||||
|
||||
var globalHandshakeReplay = &handshakeReplayProtector{}
|
||||
58
transport/sudoku/session_keys.go
Normal file
58
transport/sudoku/session_keys.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"golang.org/x/crypto/hkdf"
|
||||
)
|
||||
|
||||
func derivePSKDirectionalBases(psk string) (c2s, s2c []byte) {
|
||||
sum := sha256.Sum256([]byte(psk))
|
||||
c2sKey := make([]byte, 32)
|
||||
s2cKey := make([]byte, 32)
|
||||
if _, err := io.ReadFull(hkdf.Expand(sha256.New, sum[:], []byte("sudoku-psk-c2s")), c2sKey); err != nil {
|
||||
panic("sudoku: hkdf expand failed")
|
||||
}
|
||||
if _, err := io.ReadFull(hkdf.Expand(sha256.New, sum[:], []byte("sudoku-psk-s2c")), s2cKey); err != nil {
|
||||
panic("sudoku: hkdf expand failed")
|
||||
}
|
||||
return c2sKey, s2cKey
|
||||
}
|
||||
|
||||
func deriveSessionDirectionalBases(psk string, shared []byte, nonce [kipHelloNonceSize]byte) (c2s, s2c []byte, err error) {
|
||||
sum := sha256.Sum256([]byte(psk))
|
||||
ikm := make([]byte, 0, len(shared)+len(nonce))
|
||||
ikm = append(ikm, shared...)
|
||||
ikm = append(ikm, nonce[:]...)
|
||||
|
||||
prk := hkdf.Extract(sha256.New, ikm, sum[:])
|
||||
|
||||
c2sKey := make([]byte, 32)
|
||||
s2cKey := make([]byte, 32)
|
||||
if _, err := io.ReadFull(hkdf.Expand(sha256.New, prk, []byte("sudoku-session-c2s")), c2sKey); err != nil {
|
||||
return nil, nil, fmt.Errorf("hkdf expand c2s: %w", err)
|
||||
}
|
||||
if _, err := io.ReadFull(hkdf.Expand(sha256.New, prk, []byte("sudoku-session-s2c")), s2cKey); err != nil {
|
||||
return nil, nil, fmt.Errorf("hkdf expand s2c: %w", err)
|
||||
}
|
||||
return c2sKey, s2cKey, nil
|
||||
}
|
||||
|
||||
func x25519SharedSecret(priv *ecdh.PrivateKey, peerPub []byte) ([]byte, error) {
|
||||
if priv == nil {
|
||||
return nil, fmt.Errorf("nil priv")
|
||||
}
|
||||
curve := ecdh.X25519()
|
||||
pk, err := curve.NewPublicKey(peerPub)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse peer pub: %w", err)
|
||||
}
|
||||
secret, err := priv.ECDH(pk)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ecdh: %w", err)
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
164
transport/sudoku/table_probe.go
Normal file
164
transport/sudoku/table_probe.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
crand "crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/transport/sudoku/crypto"
|
||||
"github.com/sagernet/sing-box/transport/sudoku/obfs/sudoku"
|
||||
)
|
||||
|
||||
type clientTableChoice struct {
|
||||
Table *sudoku.Table
|
||||
Hint uint32
|
||||
HasHint bool
|
||||
}
|
||||
|
||||
func pickClientTable(cfg *ProtocolConfig) (clientTableChoice, error) {
|
||||
candidates := cfg.tableCandidates()
|
||||
if len(candidates) == 0 {
|
||||
return clientTableChoice{}, fmt.Errorf("no table configured")
|
||||
}
|
||||
if len(candidates) == 1 {
|
||||
return clientTableChoice{Table: candidates[0], Hint: candidates[0].Hint()}, nil
|
||||
}
|
||||
var b [1]byte
|
||||
if _, err := crand.Read(b[:]); err != nil {
|
||||
return clientTableChoice{}, fmt.Errorf("random table pick failed: %w", err)
|
||||
}
|
||||
idx := int(b[0]) % len(candidates)
|
||||
return clientTableChoice{Table: candidates[idx], Hint: candidates[idx].Hint(), HasHint: true}, nil
|
||||
}
|
||||
|
||||
type readOnlyConn struct {
|
||||
*bytes.Reader
|
||||
}
|
||||
|
||||
func (c *readOnlyConn) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
|
||||
func (c *readOnlyConn) Close() error { return nil }
|
||||
func (c *readOnlyConn) LocalAddr() net.Addr { return nil }
|
||||
func (c *readOnlyConn) RemoteAddr() net.Addr { return nil }
|
||||
func (c *readOnlyConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *readOnlyConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *readOnlyConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
func drainBuffered(r *bufio.Reader) ([]byte, error) {
|
||||
n := r.Buffered()
|
||||
if n <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]byte, n)
|
||||
_, err := io.ReadFull(r, out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func probeHandshakeBytes(probe []byte, cfg *ProtocolConfig, table *sudoku.Table) error {
|
||||
rc := &readOnlyConn{Reader: bytes.NewReader(probe)}
|
||||
_, obfsConn := buildServerObfsConn(rc, cfg, table, false)
|
||||
seed := ServerAEADSeed(cfg.Key)
|
||||
pskC2S, pskS2C := derivePSKDirectionalBases(seed)
|
||||
// Server side: recv is client->server, send is server->client.
|
||||
cConn, err := crypto.NewRecordConn(obfsConn, cfg.AEADMethod, pskS2C, pskC2S)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg, err := ReadKIPMessage(cConn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if msg.Type != KIPTypeClientHello {
|
||||
return fmt.Errorf("unexpected handshake message: %d", msg.Type)
|
||||
}
|
||||
ch, err := DecodeKIPClientHelloPayload(msg.Payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if absInt64(time.Now().Unix()-ch.Timestamp.Unix()) > int64(kipHandshakeSkew.Seconds()) {
|
||||
return fmt.Errorf("time skew/replay")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectTableByProbe(r *bufio.Reader, cfg *ProtocolConfig, tables []*sudoku.Table) (*sudoku.Table, []byte, error) {
|
||||
const (
|
||||
maxProbeBytes = 64 * 1024
|
||||
readChunk = 4 * 1024
|
||||
)
|
||||
if len(tables) == 0 {
|
||||
return nil, nil, fmt.Errorf("no table candidates")
|
||||
}
|
||||
if len(tables) > 255 {
|
||||
return nil, nil, fmt.Errorf("too many table candidates: %d", len(tables))
|
||||
}
|
||||
|
||||
// Copy so we can prune candidates without mutating the caller slice.
|
||||
candidates := make([]*sudoku.Table, 0, len(tables))
|
||||
for _, t := range tables {
|
||||
if t != nil {
|
||||
candidates = append(candidates, t)
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil, nil, fmt.Errorf("no table candidates")
|
||||
}
|
||||
|
||||
probe, err := drainBuffered(r)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("drain buffered bytes failed: %w", err)
|
||||
}
|
||||
|
||||
tmp := make([]byte, readChunk)
|
||||
for {
|
||||
if len(candidates) == 1 {
|
||||
tail, err := drainBuffered(r)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("drain buffered bytes failed: %w", err)
|
||||
}
|
||||
probe = append(probe, tail...)
|
||||
return candidates[0], probe, nil
|
||||
}
|
||||
|
||||
needMore := false
|
||||
next := candidates[:0]
|
||||
for _, table := range candidates {
|
||||
err := probeHandshakeBytes(probe, cfg, table)
|
||||
if err == nil {
|
||||
tail, err := drainBuffered(r)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("drain buffered bytes failed: %w", err)
|
||||
}
|
||||
probe = append(probe, tail...)
|
||||
return table, probe, nil
|
||||
}
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
needMore = true
|
||||
next = append(next, table)
|
||||
}
|
||||
// Definitive mismatch: drop table.
|
||||
}
|
||||
candidates = next
|
||||
|
||||
if len(candidates) == 0 || !needMore {
|
||||
return nil, probe, fmt.Errorf("handshake table selection failed")
|
||||
}
|
||||
if len(probe) >= maxProbeBytes {
|
||||
return nil, probe, fmt.Errorf("handshake probe exceeded %d bytes", maxProbeBytes)
|
||||
}
|
||||
|
||||
n, err := r.Read(tmp)
|
||||
if n > 0 {
|
||||
probe = append(probe, tmp[:n]...)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, probe, fmt.Errorf("handshake probe read failed: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
68
transport/sudoku/tables.go
Normal file
68
transport/sudoku/tables.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/sing-box/transport/sudoku/obfs/sudoku"
|
||||
)
|
||||
|
||||
func normalizeCustomPatterns(customTable string, customTables []string) []string {
|
||||
patterns := customTables
|
||||
if len(patterns) == 0 && strings.TrimSpace(customTable) != "" {
|
||||
patterns = []string{customTable}
|
||||
}
|
||||
if len(patterns) == 0 {
|
||||
patterns = []string{""}
|
||||
}
|
||||
return patterns
|
||||
}
|
||||
|
||||
func normalizeTablePatterns(tableType string, customTable string, customTables []string) ([]string, error) {
|
||||
patterns := normalizeCustomPatterns(customTable, customTables)
|
||||
if _, err := sudoku.ParseASCIIMode(tableType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return patterns, nil
|
||||
}
|
||||
|
||||
// NewTablesWithCustomPatterns builds one or more obfuscation tables from x/v/p custom patterns.
|
||||
// When customTables is non-empty it overrides customTable (matching upstream Sudoku behavior).
|
||||
//
|
||||
// Deprecated-ish: prefer NewClientTablesWithCustomPatterns / NewServerTablesWithCustomPatterns.
|
||||
func NewTablesWithCustomPatterns(key string, tableType string, customTable string, customTables []string) ([]*sudoku.Table, error) {
|
||||
patterns, err := normalizeTablePatterns(tableType, customTable, customTables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tables := make([]*sudoku.Table, 0, len(patterns))
|
||||
for _, pattern := range patterns {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
t, err := NewTableWithCustom(key, tableType, pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tables = append(tables, t)
|
||||
}
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
func NewClientTablesWithCustomPatterns(key string, tableType string, customTable string, customTables []string) ([]*sudoku.Table, error) {
|
||||
return NewTablesWithCustomPatterns(key, tableType, customTable, customTables)
|
||||
}
|
||||
|
||||
// NewServerTablesWithCustomPatterns matches upstream server behavior: when probeable custom table
|
||||
// rotation is enabled, also accept the default table to avoid forcing clients to update in lockstep.
|
||||
func NewServerTablesWithCustomPatterns(key string, tableType string, customTable string, customTables []string) ([]*sudoku.Table, error) {
|
||||
patterns, err := normalizeTablePatterns(tableType, customTable, customTables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
asciiMode, err := sudoku.ParseASCIIMode(tableType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if asciiMode.Uplink == "entropy" && len(patterns) > 0 && strings.TrimSpace(patterns[0]) != "" {
|
||||
patterns = append([]string{""}, patterns...)
|
||||
}
|
||||
return NewTablesWithCustomPatterns(key, tableType, "", patterns)
|
||||
}
|
||||
165
transport/sudoku/uot.go
Normal file
165
transport/sudoku/uot.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package sudoku
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
)
|
||||
|
||||
const (
|
||||
maxUoTPayload = 64 * 1024
|
||||
)
|
||||
|
||||
// WriteDatagram sends a single UDP datagram frame over a reliable stream.
|
||||
func WriteDatagram(w io.Writer, addr string, payload []byte) error {
|
||||
addrBuf, err := EncodeAddress(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode address: %w", err)
|
||||
}
|
||||
|
||||
if addrLen := len(addrBuf); addrLen == 0 || addrLen > maxUoTPayload {
|
||||
return fmt.Errorf("address too long: %d", len(addrBuf))
|
||||
}
|
||||
if payloadLen := len(payload); payloadLen > maxUoTPayload {
|
||||
return fmt.Errorf("payload too large: %d", payloadLen)
|
||||
}
|
||||
|
||||
var header [4]byte
|
||||
binary.BigEndian.PutUint16(header[:2], uint16(len(addrBuf)))
|
||||
binary.BigEndian.PutUint16(header[2:], uint16(len(payload)))
|
||||
|
||||
return writeAllChunks(w, header[:], addrBuf, payload)
|
||||
}
|
||||
|
||||
// ReadDatagram parses a single UDP datagram frame from the reliable stream.
|
||||
func ReadDatagram(r io.Reader) (string, []byte, error) {
|
||||
addr, payloadLen, err := readDatagramHeaderAndAddress(r)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
payload := make([]byte, payloadLen)
|
||||
if _, err := io.ReadFull(r, payload); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
return addr, payload, nil
|
||||
}
|
||||
|
||||
// UoTPacketConn adapts a net.Conn with the Sudoku UoT framing to net.PacketConn.
|
||||
type UoTPacketConn struct {
|
||||
conn net.Conn
|
||||
writeMu sync.Mutex
|
||||
}
|
||||
|
||||
func NewUoTPacketConn(conn net.Conn) *UoTPacketConn {
|
||||
return &UoTPacketConn{conn: conn}
|
||||
}
|
||||
|
||||
func (c *UoTPacketConn) ReadFrom(p []byte) (int, net.Addr, error) {
|
||||
for {
|
||||
addrStr, payloadLen, err := readDatagramHeaderAndAddress(c.conn)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
udpAddr, err := parseDatagramUDPAddr(addrStr)
|
||||
if payloadLen > len(p) {
|
||||
if discardErr := discardBytes(c.conn, payloadLen); discardErr != nil {
|
||||
return 0, nil, discardErr
|
||||
}
|
||||
return 0, nil, io.ErrShortBuffer
|
||||
}
|
||||
if err != nil {
|
||||
if discardErr := discardBytes(c.conn, payloadLen); discardErr != nil {
|
||||
return 0, nil, discardErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := io.ReadFull(c.conn, p[:payloadLen]); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return payloadLen, udpAddr, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *UoTPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) {
|
||||
if addr == nil {
|
||||
return 0, errors.New("address is nil")
|
||||
}
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
if err := WriteDatagram(c.conn, addr.String(), p); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (c *UoTPacketConn) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
func (c *UoTPacketConn) LocalAddr() net.Addr {
|
||||
return c.conn.LocalAddr()
|
||||
}
|
||||
|
||||
func (c *UoTPacketConn) SetDeadline(t time.Time) error {
|
||||
return c.conn.SetDeadline(t)
|
||||
}
|
||||
|
||||
func (c *UoTPacketConn) SetReadDeadline(t time.Time) error {
|
||||
return c.conn.SetReadDeadline(t)
|
||||
}
|
||||
|
||||
func (c *UoTPacketConn) SetWriteDeadline(t time.Time) error {
|
||||
return c.conn.SetWriteDeadline(t)
|
||||
}
|
||||
|
||||
func readDatagramHeaderAndAddress(r io.Reader) (string, int, error) {
|
||||
var header [4]byte
|
||||
if _, err := io.ReadFull(r, header[:]); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
addrLen := int(binary.BigEndian.Uint16(header[:2]))
|
||||
payloadLen := int(binary.BigEndian.Uint16(header[2:]))
|
||||
if addrLen <= 0 || addrLen > maxUoTPayload {
|
||||
return "", 0, fmt.Errorf("invalid address length: %d", addrLen)
|
||||
}
|
||||
if payloadLen < 0 || payloadLen > maxUoTPayload {
|
||||
return "", 0, fmt.Errorf("invalid payload length: %d", payloadLen)
|
||||
}
|
||||
|
||||
addrBuf := make([]byte, addrLen)
|
||||
if _, err := io.ReadFull(r, addrBuf); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
addr, err := DecodeAddress(bytes.NewReader(addrBuf))
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("decode address: %w", err)
|
||||
}
|
||||
return addr, payloadLen, nil
|
||||
}
|
||||
|
||||
func parseDatagramUDPAddr(addr string) (*net.UDPAddr, error) {
|
||||
addrPort, err := netip.ParseAddrPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return net.UDPAddrFromAddrPort(netip.AddrPortFrom(addrPort.Addr().Unmap(), addrPort.Port())), nil
|
||||
}
|
||||
|
||||
func discardBytes(r io.Reader, n int) error {
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := io.CopyN(io.Discard, r, int64(n))
|
||||
return err
|
||||
}
|
||||
19
transport/sudoku/write_chunks.go
Normal file
19
transport/sudoku/write_chunks.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package sudoku
|
||||
|
||||
import "io"
|
||||
|
||||
func writeAllChunks(w io.Writer, chunks ...[]byte) error {
|
||||
for _, chunk := range chunks {
|
||||
for len(chunk) > 0 {
|
||||
n, err := w.Write(chunk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
chunk = chunk[n:]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user