Compare commits

..

22 Commits

Author SHA1 Message Date
saba-futai
9033717190 feat: sudoku support ws transport (#2589) 2026-03-01 10:22:53 +08:00
enfein
dda1d525c1 fix: unable to start mieru inbound when traffic-pattern is not set (#2590) 2026-03-01 08:00:15 +08:00
Hung-I Wang
3035ae89e3 fix: correct typo in ProxyGroup health check log message (#2575) 2026-02-27 11:00:47 +08:00
enfein
c251e411e5 feat: support mieru traffic pattern configuration (#2585) 2026-02-27 10:31:35 +08:00
wwqgtxx
f6722ab79b action: Upgrade loongarch golang version 2026-02-25 13:08:18 +08:00
wwqgtxx
4ca515896b feat: support trusttunnel inbound and outbound 2026-02-25 12:26:08 +08:00
wwqgtxx
836c972c54 chore: cleanup unreachable code 2026-02-23 21:03:22 +08:00
wwqgtxx
43509da1a9 chore: simplify gun code 2026-02-23 21:03:22 +08:00
wwqgtxx
3752cb044f fix: CVE-2026-26958 of filippo.io/edwards25519 2026-02-21 17:18:15 +08:00
wwqgtxx
30391b40c4 chore: unified UA settings method 2026-02-13 18:11:46 +08:00
Panda
05fbf552ec fix: make User-Agent check case-insensitive (#2566) 2026-02-13 17:34:06 +08:00
wwqgtxx
e4143cf1ad fix: tun doesn't clean up the DNS setting in systemd-resolved when closed 2026-02-13 11:51:11 +08:00
wwqgtxx
5eaf5d16ce fix: quic gso maybe not working with pppoe 2026-02-13 11:43:20 +08:00
wwqgtxx
9dee264f13 fix: udp/icmp not work on gso with system stack 2026-02-13 00:07:39 +08:00
wwqgtxx
50480406cf fix: rollback sing-tun commit 2026-02-12 17:42:01 +08:00
wwqgtxx
6eb27ac3dc chore: align with legacy behavior 2026-02-12 16:43:45 +08:00
wwqgtxx
a949ad883c chore: update golang to 1.26 2026-02-11 17:13:19 +08:00
wwqgtxx
20bf57c117 feat: add disable-reuse params for DoT 2026-02-10 15:03:15 +08:00
wwqgtxx
60a9312057 chore: structure support remain-tagged field 2026-02-10 15:03:15 +08:00
wwqgtxx
9fda032a28 chore: structure unifies the way to handle top-level and sub structs 2026-02-10 15:03:15 +08:00
wwqgtxx
c3399fd346 chore: better logging for removed configurations 2026-02-10 01:16:24 +08:00
wwqgtxx
445083b624 fix: override interface-name broken
https://github.com/MetaCubeX/mihomo/issues/2558
2026-02-09 19:55:18 +08:00
78 changed files with 4624 additions and 1007 deletions

View File

@@ -59,8 +59,8 @@ jobs:
- { goos: linux, goarch: s390x, output: s390x, debian: s390x, rpm: s390x } - { goos: linux, goarch: s390x, output: s390x, debian: s390x, rpm: s390x }
- { goos: linux, goarch: ppc64le, output: ppc64le, debian: ppc64el, rpm: ppc64le } - { goos: linux, goarch: ppc64le, output: ppc64le, debian: ppc64el, rpm: ppc64le }
# Go 1.25 with special patch can work on Windows 7 # Go 1.26 with special patch can work on Windows 7
# https://github.com/MetaCubeX/go/commits/release-branch.go1.25/ # https://github.com/MetaCubeX/go/commits/release-branch.go1.26/
- { goos: windows, goarch: '386', output: '386' } - { goos: windows, goarch: '386', output: '386' }
- { goos: windows, goarch: amd64, goamd64: v1, output: amd64-compatible } # old style file name will be removed in next released - { goos: windows, goarch: amd64, goamd64: v1, output: amd64-compatible } # old style file name will be removed in next released
- { goos: windows, goarch: amd64, goamd64: v3, output: amd64 } - { goos: windows, goarch: amd64, goamd64: v3, output: amd64 }
@@ -82,6 +82,13 @@ jobs:
- { goos: android, goarch: arm, ndk: armv7a-linux-androideabi34, output: armv7 } - { goos: android, goarch: arm, ndk: armv7a-linux-androideabi34, output: armv7 }
- { goos: android, goarch: arm64, ndk: aarch64-linux-android34, output: arm64-v8 } - { goos: android, goarch: arm64, ndk: aarch64-linux-android34, output: arm64-v8 }
# Go 1.25 with special patch can work on Windows 7
# https://github.com/MetaCubeX/go/commits/release-branch.go1.25/
- { goos: windows, goarch: '386', output: '386-go125', goversion: '1.25' }
- { goos: windows, goarch: amd64, goamd64: v1, output: amd64-v1-go125, goversion: '1.25' }
- { goos: windows, goarch: amd64, goamd64: v2, output: amd64-v2-go125, goversion: '1.25' }
- { goos: windows, goarch: amd64, goamd64: v3, output: amd64-v3-go125, goversion: '1.25' }
# Go 1.24 with special patch can work on Windows 7 # Go 1.24 with special patch can work on Windows 7
# https://github.com/MetaCubeX/go/commits/release-branch.go1.24/ # https://github.com/MetaCubeX/go/commits/release-branch.go1.24/
- { goos: windows, goarch: '386', output: '386-go124', goversion: '1.24' } - { goos: windows, goarch: '386', output: '386-go124', goversion: '1.24' }
@@ -154,7 +161,7 @@ jobs:
if: ${{ matrix.jobs.goversion == '' && matrix.jobs.abi != '1' }} if: ${{ matrix.jobs.goversion == '' && matrix.jobs.abi != '1' }}
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version: '1.25' go-version: '1.26'
check-latest: true # Always check for the latest patch release check-latest: true # Always check for the latest patch release
- name: Set up Go - name: Set up Go
@@ -164,14 +171,30 @@ jobs:
go-version: ${{ matrix.jobs.goversion }} go-version: ${{ matrix.jobs.goversion }}
check-latest: true # Always check for the latest patch release check-latest: true # Always check for the latest patch release
- name: Set up Go1.24 loongarch abi1 - name: Set up Go1.25 loongarch abi1
if: ${{ matrix.jobs.goarch == 'loong64' && matrix.jobs.abi == '1' }} if: ${{ matrix.jobs.goarch == 'loong64' && matrix.jobs.abi == '1' }}
run: | run: |
wget -q https://github.com/MetaCubeX/loongarch64-golang/releases/download/1.24.0/go1.24.0.linux-amd64-abi1.tar.gz wget -q https://github.com/MetaCubeX/loongarch64-golang/releases/download/1.25.5/go1.25.5.linux-amd64-abi1.tar.gz
sudo tar zxf go1.24.0.linux-amd64-abi1.tar.gz -C /usr/local sudo tar zxf go1.25.5.linux-amd64-abi1.tar.gz -C /usr/local
echo "/usr/local/go/bin" >> $GITHUB_PATH echo "/usr/local/go/bin" >> $GITHUB_PATH
# modify from https://github.com/restic/restic/issues/4636#issuecomment-1896455557 # this patch file only works on golang1.26.x
# that means after golang1.27 release it must be changed
# see: https://github.com/MetaCubeX/go/commits/release-branch.go1.26/
# revert:
# 693def151adff1af707d82d28f55dba81ceb08e1: "crypto/rand,runtime: switch RtlGenRandom for ProcessPrng"
# 7c1157f9544922e96945196b47b95664b1e39108: "net: remove sysSocket fallback for Windows 7"
# 48042aa09c2f878c4faa576948b07fe625c4707a: "syscall: remove Windows 7 console handle workaround"
# a17d959debdb04cd550016a3501dd09d50cd62e7: "runtime: always use LoadLibraryEx to load system libraries"
# f0894a00f4b756d4b9b4078af2e686b359493583: "os: remove 5ms sleep on Windows in (*Process).Wait"
# sepical fix:
# - os.RemoveAll not working on Windows7
- name: Revert Golang1.26 commit for Windows7/8
if: ${{ matrix.jobs.goos == 'windows' && matrix.jobs.goversion == '' }}
run: |
cd $(go env GOROOT)
patch --verbose -p 1 < $GITHUB_WORKSPACE/.github/patch/go1.26.patch
# this patch file only works on golang1.25.x # this patch file only works on golang1.25.x
# that means after golang1.26 release it must be changed # that means after golang1.26 release it must be changed
# see: https://github.com/MetaCubeX/go/commits/release-branch.go1.25/ # see: https://github.com/MetaCubeX/go/commits/release-branch.go1.25/
@@ -184,12 +207,11 @@ jobs:
# sepical fix: # sepical fix:
# - os.RemoveAll not working on Windows7 # - os.RemoveAll not working on Windows7
- name: Revert Golang1.25 commit for Windows7/8 - name: Revert Golang1.25 commit for Windows7/8
if: ${{ matrix.jobs.goos == 'windows' && matrix.jobs.goversion == '' }} if: ${{ matrix.jobs.goos == 'windows' && matrix.jobs.goversion == '1.25' }}
run: | run: |
cd $(go env GOROOT) cd $(go env GOROOT)
patch --verbose -p 1 < $GITHUB_WORKSPACE/.github/patch/go1.25.patch patch --verbose -p 1 < $GITHUB_WORKSPACE/.github/patch/go1.25.patch
# modify from https://github.com/restic/restic/issues/4636#issuecomment-1896455557
# this patch file only works on golang1.24.x # this patch file only works on golang1.24.x
# that means after golang1.25 release it must be changed # that means after golang1.25 release it must be changed
# see: https://github.com/MetaCubeX/go/commits/release-branch.go1.24/ # see: https://github.com/MetaCubeX/go/commits/release-branch.go1.24/
@@ -204,7 +226,6 @@ jobs:
cd $(go env GOROOT) cd $(go env GOROOT)
patch --verbose -p 1 < $GITHUB_WORKSPACE/.github/patch/go1.24.patch patch --verbose -p 1 < $GITHUB_WORKSPACE/.github/patch/go1.24.patch
# modify from https://github.com/restic/restic/issues/4636#issuecomment-1896455557
# this patch file only works on golang1.23.x # this patch file only works on golang1.23.x
# that means after golang1.24 release it must be changed # that means after golang1.24 release it must be changed
# see: https://github.com/MetaCubeX/go/commits/release-branch.go1.23/ # see: https://github.com/MetaCubeX/go/commits/release-branch.go1.23/
@@ -219,7 +240,6 @@ jobs:
cd $(go env GOROOT) cd $(go env GOROOT)
patch --verbose -p 1 < $GITHUB_WORKSPACE/.github/patch/go1.23.patch patch --verbose -p 1 < $GITHUB_WORKSPACE/.github/patch/go1.23.patch
# modify from https://github.com/restic/restic/issues/4636#issuecomment-1896455557
# this patch file only works on golang1.22.x # this patch file only works on golang1.22.x
# that means after golang1.23 release it must be changed # that means after golang1.23 release it must be changed
# see: https://github.com/MetaCubeX/go/commits/release-branch.go1.22/ # see: https://github.com/MetaCubeX/go/commits/release-branch.go1.22/

View File

@@ -24,7 +24,7 @@ jobs:
- 'ubuntu-24.04-arm' # arm64 linux - 'ubuntu-24.04-arm' # arm64 linux
- 'macos-15-intel' # amd64 macos - 'macos-15-intel' # amd64 macos
go-version: go-version:
- '1.26.0-rc.3' - '1.26'
- '1.25' - '1.25'
- '1.24' - '1.24'
- '1.23' - '1.23'
@@ -51,17 +51,11 @@ jobs:
check-latest: true # Always check for the latest patch release check-latest: true # Always check for the latest patch release
- name: Revert Golang commit for Windows7/8 - name: Revert Golang commit for Windows7/8
if: ${{ runner.os == 'Windows' && matrix.go-version != '1.20' && matrix.go-version != '1.26.0-rc.3' }} if: ${{ runner.os == 'Windows' && matrix.go-version != '1.20' }}
run: | run: |
cd $(go env GOROOT) cd $(go env GOROOT)
patch --verbose -p 1 < $GITHUB_WORKSPACE/.github/patch/go${{matrix.go-version}}.patch patch --verbose -p 1 < $GITHUB_WORKSPACE/.github/patch/go${{matrix.go-version}}.patch
- name: Revert Golang commit for Windows7/8
if: ${{ runner.os == 'Windows' && matrix.go-version == '1.26.0-rc.3' }}
run: |
cd $(go env GOROOT)
patch --verbose -p 1 < $GITHUB_WORKSPACE/.github/patch/go1.26.patch
- name: Remove inbound test for macOS - name: Remove inbound test for macOS
if: ${{ runner.os == 'macOS' }} if: ${{ runner.os == 'macOS' }}
run: | run: |

View File

@@ -15,6 +15,7 @@ import (
mieruclient "github.com/enfein/mieru/v3/apis/client" mieruclient "github.com/enfein/mieru/v3/apis/client"
mierucommon "github.com/enfein/mieru/v3/apis/common" mierucommon "github.com/enfein/mieru/v3/apis/common"
mierumodel "github.com/enfein/mieru/v3/apis/model" mierumodel "github.com/enfein/mieru/v3/apis/model"
mierutp "github.com/enfein/mieru/v3/apis/trafficpattern"
mierupb "github.com/enfein/mieru/v3/pkg/appctl/appctlpb" mierupb "github.com/enfein/mieru/v3/pkg/appctl/appctlpb"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
) )
@@ -28,16 +29,17 @@ type Mieru struct {
type MieruOption struct { type MieruOption struct {
BasicOption BasicOption
Name string `proxy:"name"` Name string `proxy:"name"`
Server string `proxy:"server"` Server string `proxy:"server"`
Port int `proxy:"port,omitempty"` Port int `proxy:"port,omitempty"`
PortRange string `proxy:"port-range,omitempty"` PortRange string `proxy:"port-range,omitempty"`
Transport string `proxy:"transport"` Transport string `proxy:"transport"`
UDP bool `proxy:"udp,omitempty"` UDP bool `proxy:"udp,omitempty"`
UserName string `proxy:"username"` UserName string `proxy:"username"`
Password string `proxy:"password"` Password string `proxy:"password"`
Multiplexing string `proxy:"multiplexing,omitempty"` Multiplexing string `proxy:"multiplexing,omitempty"`
HandshakeMode string `proxy:"handshake-mode,omitempty"` HandshakeMode string `proxy:"handshake-mode,omitempty"`
TrafficPattern string `proxy:"traffic-pattern,omitempty"`
} }
type mieruPacketDialer struct { type mieruPacketDialer struct {
@@ -291,6 +293,10 @@ func buildMieruClientConfig(option MieruOption) (*mieruclient.ClientConfig, erro
if handshakeMode, ok := mierupb.HandshakeMode_value[option.HandshakeMode]; ok { if handshakeMode, ok := mierupb.HandshakeMode_value[option.HandshakeMode]; ok {
config.Profile.HandshakeMode = (*mierupb.HandshakeMode)(&handshakeMode) config.Profile.HandshakeMode = (*mierupb.HandshakeMode)(&handshakeMode)
} }
if option.TrafficPattern != "" {
trafficPattern, _ := mierutp.Decode(option.TrafficPattern)
config.Profile.TrafficPattern = trafficPattern
}
return config, nil return config, nil
} }
@@ -345,6 +351,15 @@ func validateMieruOption(option MieruOption) error {
return fmt.Errorf("invalid handshake mode: %s", option.HandshakeMode) return fmt.Errorf("invalid handshake mode: %s", option.HandshakeMode)
} }
} }
if option.TrafficPattern != "" {
trafficPattern, err := mierutp.Decode(option.TrafficPattern)
if err != nil {
return fmt.Errorf("failed to decode traffic pattern %q: %w", option.TrafficPattern, err)
}
if err := mierutp.Validate(trafficPattern); err != nil {
return fmt.Errorf("invalid traffic pattern %q: %w", option.TrafficPattern, err)
}
}
return nil return nil
} }

View File

@@ -31,12 +31,13 @@ func TestNewMieru(t *testing.T) {
}, },
{ {
option: MieruOption{ option: MieruOption{
Name: "test", Name: "test",
Server: "example.com", Server: "example.com",
Port: 10003, Port: 10003,
Transport: "UDP", Transport: "UDP",
UserName: "test", UserName: "test",
Password: "test", Password: "test",
TrafficPattern: "GgQIARAK",
}, },
wantBaseAddr: "example.com:10003", wantBaseAddr: "example.com:10003",
}, },

View File

@@ -12,9 +12,9 @@ import (
type RealityOptions struct { type RealityOptions struct {
PublicKey string `proxy:"public-key"` PublicKey string `proxy:"public-key"`
ShortID string `proxy:"short-id"` ShortID string `proxy:"short-id,omitempty"`
SupportX25519MLKEM768 bool `proxy:"support-x25519mlkem768"` SupportX25519MLKEM768 bool `proxy:"support-x25519mlkem768,omitempty"`
} }
func (o RealityOptions) Parse() (*tlsC.RealityConfig, error) { func (o RealityOptions) Parse() (*tlsC.RealityConfig, error) {

View File

@@ -11,6 +11,7 @@ import (
N "github.com/metacubex/mihomo/common/net" N "github.com/metacubex/mihomo/common/net"
C "github.com/metacubex/mihomo/constant" C "github.com/metacubex/mihomo/constant"
"github.com/metacubex/mihomo/transport/sudoku" "github.com/metacubex/mihomo/transport/sudoku"
"github.com/metacubex/mihomo/transport/sudoku/obfs/httpmask"
) )
type Sudoku struct { type Sudoku struct {
@@ -18,32 +19,43 @@ type Sudoku struct {
option *SudokuOption option *SudokuOption
baseConf sudoku.ProtocolConfig baseConf sudoku.ProtocolConfig
httpMaskMu sync.Mutex
httpMaskClient *sudoku.HTTPMaskTunnelClient
muxMu sync.Mutex muxMu sync.Mutex
muxClient *sudoku.MultiplexClient muxClient *sudoku.MultiplexClient
httpMaskMu sync.Mutex
httpMaskClient *httpmask.TunnelClient
httpMaskKey string
} }
type SudokuOption struct { type SudokuOption struct {
BasicOption BasicOption
Name string `proxy:"name"` Name string `proxy:"name"`
Server string `proxy:"server"` Server string `proxy:"server"`
Port int `proxy:"port"` Port int `proxy:"port"`
Key string `proxy:"key"` Key string `proxy:"key"`
AEADMethod string `proxy:"aead-method,omitempty"` AEADMethod string `proxy:"aead-method,omitempty"`
PaddingMin *int `proxy:"padding-min,omitempty"` PaddingMin *int `proxy:"padding-min,omitempty"`
PaddingMax *int `proxy:"padding-max,omitempty"` PaddingMax *int `proxy:"padding-max,omitempty"`
TableType string `proxy:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy" TableType string `proxy:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy"
EnablePureDownlink *bool `proxy:"enable-pure-downlink,omitempty"` EnablePureDownlink *bool `proxy:"enable-pure-downlink,omitempty"`
HTTPMask bool `proxy:"http-mask,omitempty"` HTTPMask *bool `proxy:"http-mask,omitempty"`
HTTPMaskMode string `proxy:"http-mask-mode,omitempty"` // "legacy" (default), "stream", "poll", "auto" HTTPMaskMode string `proxy:"http-mask-mode,omitempty"` // "legacy" (default), "stream", "poll", "auto", "ws"
HTTPMaskTLS bool `proxy:"http-mask-tls,omitempty"` // only for http-mask-mode stream/poll/auto HTTPMaskTLS bool `proxy:"http-mask-tls,omitempty"` // only for http-mask-mode stream/poll/auto
HTTPMaskHost string `proxy:"http-mask-host,omitempty"` // optional Host/SNI override (domain or domain:port) HTTPMaskHost string `proxy:"http-mask-host,omitempty"` // optional Host/SNI override (domain or domain:port)
PathRoot string `proxy:"path-root,omitempty"` // optional first-level path prefix for HTTP tunnel endpoints PathRoot string `proxy:"path-root,omitempty"` // optional first-level path prefix for HTTP tunnel endpoints
HTTPMaskMultiplex string `proxy:"http-mask-multiplex,omitempty"` // "off" (default), "auto" (reuse h1/h2), "on" (single tunnel, multi-target) HTTPMaskMultiplex string `proxy:"http-mask-multiplex,omitempty"` // "off" (default), "auto" (reuse h1/h2), "on" (single tunnel, multi-target)
CustomTable string `proxy:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv HTTPMaskOptions *SudokuHTTPMaskOptions `proxy:"httpmask,omitempty"`
CustomTables []string `proxy:"custom-tables,omitempty"` // optional table rotation patterns, overrides custom-table when non-empty CustomTable string `proxy:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv
CustomTables []string `proxy:"custom-tables,omitempty"` // optional table rotation patterns, overrides custom-table when non-empty
}
type SudokuHTTPMaskOptions struct {
Disable bool `proxy:"disable,omitempty"`
Mode string `proxy:"mode,omitempty"`
TLS bool `proxy:"tls,omitempty"`
Host string `proxy:"host,omitempty"`
PathRoot string `proxy:"path_root,omitempty"`
Multiplex string `proxy:"multiplex,omitempty"`
} }
// DialContext implements C.ProxyAdapter // DialContext implements C.ProxyAdapter
@@ -73,7 +85,7 @@ func (s *Sudoku) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Con
return nil, fmt.Errorf("encode target address failed: %w", err) return nil, fmt.Errorf("encode target address failed: %w", err)
} }
if _, err = c.Write(addrBuf); err != nil { if err = sudoku.WriteKIPMessage(c, sudoku.KIPTypeOpenTCP, addrBuf); err != nil {
return nil, fmt.Errorf("send target address failed: %w", err) return nil, fmt.Errorf("send target address failed: %w", err)
} }
@@ -96,9 +108,9 @@ func (s *Sudoku) ListenPacketContext(ctx context.Context, metadata *C.Metadata)
return nil, err return nil, err
} }
if err = sudoku.WritePreface(c); err != nil { if err = sudoku.WriteKIPMessage(c, sudoku.KIPTypeStartUoT, nil); err != nil {
_ = c.Close() _ = c.Close()
return nil, fmt.Errorf("send uot preface failed: %w", err) return nil, fmt.Errorf("start uot failed: %w", err)
} }
return newPacketConn(N.NewThreadSafePacketConn(sudoku.NewUoTPacketConn(c)), s), nil return newPacketConn(N.NewThreadSafePacketConn(sudoku.NewUoTPacketConn(c)), s), nil
@@ -141,32 +153,45 @@ func NewSudoku(option SudokuOption) (*Sudoku, error) {
return nil, fmt.Errorf("key is required") return nil, fmt.Errorf("key is required")
} }
tableType := strings.ToLower(option.TableType) defaultConf := sudoku.DefaultConfig()
if tableType == "" { tableType, err := sudoku.NormalizeTableType(option.TableType)
tableType = "prefer_ascii" if err != nil {
return nil, err
} }
if tableType != "prefer_ascii" && tableType != "prefer_entropy" { paddingMin, paddingMax := sudoku.ResolvePadding(option.PaddingMin, option.PaddingMax, defaultConf.PaddingMin, defaultConf.PaddingMax)
return nil, fmt.Errorf("table-type must be prefer_ascii or prefer_entropy") enablePureDownlink := sudoku.DerefBool(option.EnablePureDownlink, defaultConf.EnablePureDownlink)
disableHTTPMask := defaultConf.DisableHTTPMask
if option.HTTPMask != nil {
disableHTTPMask = !*option.HTTPMask
}
httpMaskMode := defaultConf.HTTPMaskMode
if option.HTTPMaskMode != "" {
httpMaskMode = option.HTTPMaskMode
}
httpMaskTLS := option.HTTPMaskTLS
httpMaskHost := option.HTTPMaskHost
pathRoot := strings.TrimSpace(option.PathRoot)
httpMaskMultiplex := defaultConf.HTTPMaskMultiplex
if option.HTTPMaskMultiplex != "" {
httpMaskMultiplex = option.HTTPMaskMultiplex
} }
defaultConf := sudoku.DefaultConfig() if hm := option.HTTPMaskOptions; hm != nil {
paddingMin := defaultConf.PaddingMin disableHTTPMask = hm.Disable
paddingMax := defaultConf.PaddingMax if hm.Mode != "" {
if option.PaddingMin != nil { httpMaskMode = hm.Mode
paddingMin = *option.PaddingMin }
} httpMaskTLS = hm.TLS
if option.PaddingMax != nil { httpMaskHost = hm.Host
paddingMax = *option.PaddingMax if pr := strings.TrimSpace(hm.PathRoot); pr != "" {
} pathRoot = pr
if option.PaddingMin == nil && option.PaddingMax != nil && paddingMax < paddingMin { }
paddingMin = paddingMax if mux := strings.TrimSpace(hm.Multiplex); mux != "" {
} httpMaskMultiplex = mux
if option.PaddingMax == nil && option.PaddingMin != nil && paddingMax < paddingMin { } else {
paddingMax = paddingMin httpMaskMultiplex = defaultConf.HTTPMaskMultiplex
} }
enablePureDownlink := defaultConf.EnablePureDownlink
if option.EnablePureDownlink != nil {
enablePureDownlink = *option.EnablePureDownlink
} }
baseConf := sudoku.ProtocolConfig{ baseConf := sudoku.ProtocolConfig{
@@ -177,20 +202,14 @@ func NewSudoku(option SudokuOption) (*Sudoku, error) {
PaddingMax: paddingMax, PaddingMax: paddingMax,
EnablePureDownlink: enablePureDownlink, EnablePureDownlink: enablePureDownlink,
HandshakeTimeoutSeconds: defaultConf.HandshakeTimeoutSeconds, HandshakeTimeoutSeconds: defaultConf.HandshakeTimeoutSeconds,
DisableHTTPMask: !option.HTTPMask, DisableHTTPMask: disableHTTPMask,
HTTPMaskMode: defaultConf.HTTPMaskMode, HTTPMaskMode: httpMaskMode,
HTTPMaskTLSEnabled: option.HTTPMaskTLS, HTTPMaskTLSEnabled: httpMaskTLS,
HTTPMaskHost: option.HTTPMaskHost, HTTPMaskHost: httpMaskHost,
HTTPMaskPathRoot: strings.TrimSpace(option.PathRoot), HTTPMaskPathRoot: pathRoot,
HTTPMaskMultiplex: defaultConf.HTTPMaskMultiplex, HTTPMaskMultiplex: httpMaskMultiplex,
} }
if option.HTTPMaskMode != "" { tables, err := sudoku.NewClientTablesWithCustomPatterns(sudoku.ClientAEADSeed(option.Key), tableType, option.CustomTable, option.CustomTables)
baseConf.HTTPMaskMode = option.HTTPMaskMode
}
if option.HTTPMaskMultiplex != "" {
baseConf.HTTPMaskMultiplex = option.HTTPMaskMultiplex
}
tables, err := sudoku.NewTablesWithCustomPatterns(sudoku.ClientAEADSeed(option.Key), tableType, option.CustomTable, option.CustomTables)
if err != nil { if err != nil {
return nil, fmt.Errorf("build table(s) failed: %w", err) return nil, fmt.Errorf("build table(s) failed: %w", err)
} }
@@ -244,7 +263,7 @@ func normalizeHTTPMaskMultiplex(mode string) string {
func httpTunnelModeEnabled(mode string) bool { func httpTunnelModeEnabled(mode string) bool {
switch strings.ToLower(strings.TrimSpace(mode)) { switch strings.ToLower(strings.TrimSpace(mode)) {
case "stream", "poll", "auto": case "stream", "poll", "auto", "ws":
return true return true
default: default:
return false return false
@@ -271,14 +290,24 @@ func (s *Sudoku) dialAndHandshake(ctx context.Context, cfg *sudoku.ProtocolConfi
) )
if !cfg.DisableHTTPMask && httpTunnelModeEnabled(cfg.HTTPMaskMode) { if !cfg.DisableHTTPMask && httpTunnelModeEnabled(cfg.HTTPMaskMode) {
muxMode := normalizeHTTPMaskMultiplex(cfg.HTTPMaskMultiplex) muxMode := normalizeHTTPMaskMultiplex(cfg.HTTPMaskMultiplex)
switch muxMode { if muxMode == "auto" && strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) != "ws" {
case "auto", "on": if client, cerr := s.getOrCreateHTTPMaskClient(cfg); cerr == nil && client != nil {
client, errX := s.getOrCreateHTTPMaskClient(cfg) c, err = client.DialTunnel(ctx, httpmask.TunnelDialOptions{
if errX != nil { Mode: cfg.HTTPMaskMode,
return nil, errX TLSEnabled: cfg.HTTPMaskTLSEnabled,
HostOverride: cfg.HTTPMaskHost,
PathRoot: cfg.HTTPMaskPathRoot,
AuthKey: sudoku.ClientAEADSeed(cfg.Key),
Upgrade: upgrade,
Multiplex: cfg.HTTPMaskMultiplex,
DialContext: s.dialer.DialContext,
})
if err != nil {
s.resetHTTPMaskClient()
}
} }
c, err = client.Dial(ctx, upgrade) }
default: if c == nil && err == nil {
c, err = sudoku.DialHTTPMaskTunnel(ctx, cfg.ServerAddress, cfg, s.dialer.DialContext, upgrade) c, err = sudoku.DialHTTPMaskTunnel(ctx, cfg.ServerAddress, cfg, s.dialer.DialContext, upgrade)
} }
if err == nil && c != nil { if err == nil && c != nil {
@@ -372,34 +401,51 @@ func (s *Sudoku) resetMuxClient() {
} }
} }
func (s *Sudoku) getOrCreateHTTPMaskClient(cfg *sudoku.ProtocolConfig) (*sudoku.HTTPMaskTunnelClient, error) {
if s == nil {
return nil, fmt.Errorf("nil adapter")
}
if cfg == nil {
return nil, fmt.Errorf("config is required")
}
s.httpMaskMu.Lock()
defer s.httpMaskMu.Unlock()
if s.httpMaskClient != nil {
return s.httpMaskClient, nil
}
c, err := sudoku.NewHTTPMaskTunnelClient(cfg.ServerAddress, cfg, s.dialer.DialContext)
if err != nil {
return nil, err
}
s.httpMaskClient = c
return c, nil
}
func (s *Sudoku) resetHTTPMaskClient() { func (s *Sudoku) resetHTTPMaskClient() {
s.httpMaskMu.Lock() s.httpMaskMu.Lock()
defer s.httpMaskMu.Unlock() defer s.httpMaskMu.Unlock()
if s.httpMaskClient != nil { if s.httpMaskClient != nil {
s.httpMaskClient.CloseIdleConnections() s.httpMaskClient.CloseIdleConnections()
s.httpMaskClient = nil s.httpMaskClient = nil
s.httpMaskKey = ""
} }
} }
func (s *Sudoku) getOrCreateHTTPMaskClient(cfg *sudoku.ProtocolConfig) (*httpmask.TunnelClient, error) {
if s == nil || cfg == nil {
return nil, fmt.Errorf("nil adapter or config")
}
key := cfg.ServerAddress + "|" + strconv.FormatBool(cfg.HTTPMaskTLSEnabled) + "|" + strings.TrimSpace(cfg.HTTPMaskHost)
s.httpMaskMu.Lock()
if s.httpMaskClient != nil && s.httpMaskKey == key {
client := s.httpMaskClient
s.httpMaskMu.Unlock()
return client, nil
}
s.httpMaskMu.Unlock()
client, err := httpmask.NewTunnelClient(cfg.ServerAddress, httpmask.TunnelClientOptions{
TLSEnabled: cfg.HTTPMaskTLSEnabled,
HostOverride: cfg.HTTPMaskHost,
DialContext: s.dialer.DialContext,
MaxIdleConns: 32,
})
if err != nil {
return nil, err
}
s.httpMaskMu.Lock()
defer s.httpMaskMu.Unlock()
if s.httpMaskClient != nil && s.httpMaskKey == key {
client.CloseIdleConnections()
return s.httpMaskClient, nil
}
if s.httpMaskClient != nil {
s.httpMaskClient.CloseIdleConnections()
}
s.httpMaskClient = client
s.httpMaskKey = key
return client, nil
}

View File

@@ -27,9 +27,8 @@ type Trojan struct {
hexPassword [trojan.KeyLength]byte hexPassword [trojan.KeyLength]byte
// for gun mux // for gun mux
gunTLSConfig *tls.Config
gunConfig *gun.Config gunConfig *gun.Config
transport *gun.TransportWrap gunTransport *gun.TransportWrap
realityConfig *tlsC.RealityConfig realityConfig *tlsC.RealityConfig
echConfig *ech.Config echConfig *ech.Config
@@ -66,7 +65,6 @@ type TrojanSSOption struct {
Password string `proxy:"password,omitempty"` Password string `proxy:"password,omitempty"`
} }
// StreamConnContext implements C.ProxyAdapter
func (t *Trojan) StreamConnContext(ctx context.Context, c net.Conn, metadata *C.Metadata) (_ net.Conn, err error) { func (t *Trojan) StreamConnContext(ctx context.Context, c net.Conn, metadata *C.Metadata) (_ net.Conn, err error) {
switch t.option.Network { switch t.option.Network {
case "ws": case "ws":
@@ -118,7 +116,7 @@ func (t *Trojan) StreamConnContext(ctx context.Context, c net.Conn, metadata *C.
c, err = vmess.StreamWebsocketConn(ctx, c, wsOpts) c, err = vmess.StreamWebsocketConn(ctx, c, wsOpts)
case "grpc": case "grpc":
c, err = gun.StreamGunWithConn(c, t.gunTLSConfig, t.gunConfig, t.echConfig, t.realityConfig) break // already handle in gun transport
default: default:
// default tcp network // default tcp network
// handle TLS // handle TLS
@@ -179,27 +177,14 @@ func (t *Trojan) writeHeaderContext(ctx context.Context, c net.Conn, metadata *C
func (t *Trojan) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Conn, err error) { func (t *Trojan) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Conn, err error) {
var c net.Conn var c net.Conn
// gun transport // gun transport
if t.transport != nil { if t.gunTransport != nil {
c, err = gun.StreamGunWithTransport(t.transport, t.gunConfig) c, err = gun.StreamGunWithTransport(t.gunTransport, t.gunConfig)
if err != nil { } else {
return nil, err c, err = t.dialer.DialContext(ctx, "tcp", t.addr)
}
defer func(c net.Conn) {
safeConnClose(c, err)
}(c)
c, err = t.streamConnContext(ctx, c, metadata)
if err != nil {
return nil, err
}
return NewConn(c, t), nil
} }
c, err = t.dialer.DialContext(ctx, "tcp", t.addr)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s connect error: %w", t.addr, err) return nil, fmt.Errorf("%s connect error: %w", t.addr, err)
} }
defer func(c net.Conn) { defer func(c net.Conn) {
safeConnClose(c, err) safeConnClose(c, err)
}(c) }(c)
@@ -219,35 +204,19 @@ func (t *Trojan) ListenPacketContext(ctx context.Context, metadata *C.Metadata)
} }
var c net.Conn var c net.Conn
// grpc transport // grpc transport
if t.transport != nil { if t.gunTransport != nil {
c, err = gun.StreamGunWithTransport(t.transport, t.gunConfig) c, err = gun.StreamGunWithTransport(t.gunTransport, t.gunConfig)
if err != nil { } else {
return nil, fmt.Errorf("%s connect error: %w", t.addr, err) c, err = t.dialer.DialContext(ctx, "tcp", t.addr)
}
defer func(c net.Conn) {
safeConnClose(c, err)
}(c)
c, err = t.streamConnContext(ctx, c, metadata)
if err != nil {
return nil, err
}
pc := trojan.NewPacketConn(c)
return newPacketConn(pc, t), err
} }
if err = t.ResolveUDP(ctx, metadata); err != nil {
return nil, err
}
c, err = t.dialer.DialContext(ctx, "tcp", t.addr)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s connect error: %w", t.addr, err) return nil, fmt.Errorf("%s connect error: %w", t.addr, err)
} }
defer func(c net.Conn) { defer func(c net.Conn) {
safeConnClose(c, err) safeConnClose(c, err)
}(c) }(c)
c, err = t.StreamConnContext(ctx, c, metadata) c, err = t.StreamConnContext(ctx, c, metadata)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -271,8 +240,8 @@ func (t *Trojan) ProxyInfo() C.ProxyInfo {
// Close implements C.ProxyAdapter // Close implements C.ProxyAdapter
func (t *Trojan) Close() error { func (t *Trojan) Close() error {
if t.transport != nil { if t.gunTransport != nil {
return t.transport.Close() return t.gunTransport.Close()
} }
return nil return nil
} }
@@ -336,29 +305,24 @@ func NewTrojan(option TrojanOption) (*Trojan, error) {
return c, nil return c, nil
} }
tlsConfig, err := ca.GetTLSConfig(ca.Option{ tlsConfig := &vmess.TLSConfig{
TLSConfig: &tls.Config{ Host: option.SNI,
NextProtos: option.ALPN, SkipCertVerify: option.SkipCertVerify,
MinVersion: tls.VersionTLS12, FingerPrint: option.Fingerprint,
InsecureSkipVerify: option.SkipCertVerify, Certificate: option.Certificate,
ServerName: option.SNI, PrivateKey: option.PrivateKey,
}, ClientFingerprint: option.ClientFingerprint,
Fingerprint: option.Fingerprint, NextProtos: []string{"h2"},
Certificate: option.Certificate, ECH: t.echConfig,
PrivateKey: option.PrivateKey, Reality: t.realityConfig,
})
if err != nil {
return nil, err
} }
t.transport = gun.NewHTTP2Client(dialFn, tlsConfig, option.ClientFingerprint, t.echConfig, t.realityConfig) t.gunTransport = gun.NewHTTP2Client(dialFn, tlsConfig)
t.gunTLSConfig = tlsConfig
t.gunConfig = &gun.Config{ t.gunConfig = &gun.Config{
ServiceName: option.GrpcOpts.GrpcServiceName, ServiceName: option.GrpcOpts.GrpcServiceName,
UserAgent: option.GrpcOpts.GrpcUserAgent, UserAgent: option.GrpcOpts.GrpcUserAgent,
Host: option.SNI, Host: option.SNI,
ClientFingerprint: option.ClientFingerprint,
} }
} }

View File

@@ -0,0 +1,144 @@
package outbound
import (
"context"
"net"
"net/netip"
"strconv"
N "github.com/metacubex/mihomo/common/net"
C "github.com/metacubex/mihomo/constant"
"github.com/metacubex/mihomo/transport/trusttunnel"
"github.com/metacubex/mihomo/transport/vmess"
)
type TrustTunnel struct {
*Base
client *trusttunnel.Client
option *TrustTunnelOption
}
type TrustTunnelOption struct {
BasicOption
Name string `proxy:"name"`
Server string `proxy:"server"`
Port int `proxy:"port"`
UserName string `proxy:"username,omitempty"`
Password string `proxy:"password,omitempty"`
ALPN []string `proxy:"alpn,omitempty"`
SNI string `proxy:"sni,omitempty"`
ECHOpts ECHOptions `proxy:"ech-opts,omitempty"`
ClientFingerprint string `proxy:"client-fingerprint,omitempty"`
SkipCertVerify bool `proxy:"skip-cert-verify,omitempty"`
Fingerprint string `proxy:"fingerprint,omitempty"`
Certificate string `proxy:"certificate,omitempty"`
PrivateKey string `proxy:"private-key,omitempty"`
UDP bool `proxy:"udp,omitempty"`
HealthCheck bool `proxy:"health-check,omitempty"`
Quic bool `proxy:"quic,omitempty"`
CongestionController string `proxy:"congestion-controller,omitempty"`
CWND int `proxy:"cwnd,omitempty"`
}
func (t *TrustTunnel) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Conn, err error) {
c, err := t.client.Dial(ctx, metadata.RemoteAddress())
if err != nil {
return nil, err
}
return NewConn(c, t), nil
}
func (t *TrustTunnel) ListenPacketContext(ctx context.Context, metadata *C.Metadata) (_ C.PacketConn, err error) {
if err = t.ResolveUDP(ctx, metadata); err != nil {
return nil, err
}
pc, err := t.client.ListenPacket(ctx)
if err != nil {
return nil, err
}
return newPacketConn(N.NewThreadSafePacketConn(pc), t), nil
}
// SupportUOT implements C.ProxyAdapter
func (t *TrustTunnel) SupportUOT() bool {
return true
}
// ProxyInfo implements C.ProxyAdapter
func (t *TrustTunnel) ProxyInfo() C.ProxyInfo {
info := t.Base.ProxyInfo()
info.DialerProxy = t.option.DialerProxy
return info
}
// Close implements C.ProxyAdapter
func (t *TrustTunnel) Close() error {
return t.client.Close()
}
func NewTrustTunnel(option TrustTunnelOption) (*TrustTunnel, error) {
addr := net.JoinHostPort(option.Server, strconv.Itoa(option.Port))
outbound := &TrustTunnel{
Base: &Base{
name: option.Name,
addr: addr,
tp: C.TrustTunnel,
pdName: option.ProviderName,
udp: option.UDP,
tfo: option.TFO,
mpTcp: option.MPTCP,
iface: option.Interface,
rmark: option.RoutingMark,
prefer: option.IPVersion,
},
option: &option,
}
outbound.dialer = option.NewDialer(outbound.DialOptions())
tOption := trusttunnel.ClientOptions{
Dialer: outbound.dialer,
ResolvUDP: func(ctx context.Context, server string) (netip.AddrPort, error) {
udpAddr, err := resolveUDPAddr(ctx, "udp", server, option.IPVersion)
if err != nil {
return netip.AddrPort{}, err
}
return udpAddr.AddrPort(), nil
},
Server: addr,
Username: option.UserName,
Password: option.Password,
QUIC: option.Quic,
QUICCongestionControl: option.CongestionController,
QUICCwnd: option.CWND,
HealthCheck: option.HealthCheck,
}
echConfig, err := option.ECHOpts.Parse()
if err != nil {
return nil, err
}
tlsConfig := &vmess.TLSConfig{
Host: option.SNI,
SkipCertVerify: option.SkipCertVerify,
NextProtos: option.ALPN,
FingerPrint: option.Fingerprint,
Certificate: option.Certificate,
PrivateKey: option.PrivateKey,
ClientFingerprint: option.ClientFingerprint,
ECH: echConfig,
}
if tlsConfig.Host == "" {
tlsConfig.Host = option.Server
}
tOption.TLSConfig = tlsConfig
client, err := trusttunnel.NewClient(context.TODO(), tOption)
if err != nil {
return nil, err
}
outbound.client = client
return outbound, nil
}

View File

@@ -33,9 +33,8 @@ type Vless struct {
encryption *encryption.ClientInstance encryption *encryption.ClientInstance
// for gun mux // for gun mux
gunTLSConfig *tls.Config
gunConfig *gun.Config gunConfig *gun.Config
transport *gun.TransportWrap gunTransport *gun.TransportWrap
realityConfig *tlsC.RealityConfig realityConfig *tlsC.RealityConfig
echConfig *ech.Config echConfig *ech.Config
@@ -151,7 +150,7 @@ func (v *Vless) StreamConnContext(ctx context.Context, c net.Conn, metadata *C.M
c, err = vmess.StreamH2Conn(ctx, c, h2Opts) c, err = vmess.StreamH2Conn(ctx, c, h2Opts)
case "grpc": case "grpc":
c, err = gun.StreamGunWithConn(c, v.gunTLSConfig, v.gunConfig, v.echConfig, v.realityConfig) break // already handle in gun transport
default: default:
// default tcp network // default tcp network
// handle TLS // handle TLS
@@ -234,23 +233,11 @@ func (v *Vless) streamTLSConn(ctx context.Context, conn net.Conn, isH2 bool) (ne
func (v *Vless) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Conn, err error) { func (v *Vless) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Conn, err error) {
var c net.Conn var c net.Conn
// gun transport // gun transport
if v.transport != nil { if v.gunTransport != nil {
c, err = gun.StreamGunWithTransport(v.transport, v.gunConfig) c, err = gun.StreamGunWithTransport(v.gunTransport, v.gunConfig)
if err != nil { } else {
return nil, err c, err = v.dialer.DialContext(ctx, "tcp", v.addr)
}
defer func(c net.Conn) {
safeConnClose(c, err)
}(c)
c, err = v.streamConnContext(ctx, c, metadata)
if err != nil {
return nil, err
}
return NewConn(c, v), nil
} }
c, err = v.dialer.DialContext(ctx, "tcp", v.addr)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s connect error: %s", v.addr, err.Error()) return nil, fmt.Errorf("%s connect error: %s", v.addr, err.Error())
} }
@@ -272,28 +259,11 @@ func (v *Vless) ListenPacketContext(ctx context.Context, metadata *C.Metadata) (
} }
var c net.Conn var c net.Conn
// gun transport // gun transport
if v.transport != nil { if v.gunTransport != nil {
c, err = gun.StreamGunWithTransport(v.transport, v.gunConfig) c, err = gun.StreamGunWithTransport(v.gunTransport, v.gunConfig)
if err != nil { } else {
return nil, err c, err = v.dialer.DialContext(ctx, "tcp", v.addr)
}
defer func(c net.Conn) {
safeConnClose(c, err)
}(c)
c, err = v.streamConnContext(ctx, c, metadata)
if err != nil {
return nil, fmt.Errorf("new vless client error: %v", err)
}
return v.ListenPacketOnStreamConn(ctx, c, metadata)
} }
if err = v.ResolveUDP(ctx, metadata); err != nil {
return nil, err
}
c, err = v.dialer.DialContext(ctx, "tcp", v.addr)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s connect error: %s", v.addr, err.Error()) return nil, fmt.Errorf("%s connect error: %s", v.addr, err.Error())
} }
@@ -348,8 +318,8 @@ func (v *Vless) ProxyInfo() C.ProxyInfo {
// Close implements C.ProxyAdapter // Close implements C.ProxyAdapter
func (v *Vless) Close() error { func (v *Vless) Close() error {
if v.transport != nil { if v.gunTransport != nil {
return v.transport.Close() return v.gunTransport.Close()
} }
return nil return nil
} }
@@ -461,38 +431,35 @@ func NewVless(option VlessOption) (*Vless, error) {
} }
gunConfig := &gun.Config{ gunConfig := &gun.Config{
ServiceName: v.option.GrpcOpts.GrpcServiceName, ServiceName: option.GrpcOpts.GrpcServiceName,
UserAgent: v.option.GrpcOpts.GrpcUserAgent, UserAgent: option.GrpcOpts.GrpcUserAgent,
Host: v.option.ServerName, Host: option.ServerName,
ClientFingerprint: v.option.ClientFingerprint,
} }
if option.ServerName == "" { if option.ServerName == "" {
gunConfig.Host = v.addr gunConfig.Host = v.addr
} }
var tlsConfig *tls.Config var tlsConfig *vmess.TLSConfig
if option.TLS { if option.TLS {
tlsConfig, err = ca.GetTLSConfig(ca.Option{ tlsConfig = &vmess.TLSConfig{
TLSConfig: &tls.Config{ Host: option.ServerName,
InsecureSkipVerify: v.option.SkipCertVerify, SkipCertVerify: option.SkipCertVerify,
ServerName: v.option.ServerName, FingerPrint: option.Fingerprint,
}, Certificate: option.Certificate,
Fingerprint: v.option.Fingerprint, PrivateKey: option.PrivateKey,
Certificate: v.option.Certificate, ClientFingerprint: option.ClientFingerprint,
PrivateKey: v.option.PrivateKey, NextProtos: []string{"h2"},
}) ECH: v.echConfig,
if err != nil { Reality: v.realityConfig,
return nil, err
} }
if option.ServerName == "" { if option.ServerName == "" {
host, _, _ := net.SplitHostPort(v.addr) host, _, _ := net.SplitHostPort(v.addr)
tlsConfig.ServerName = host tlsConfig.Host = host
} }
} }
v.gunTLSConfig = tlsConfig
v.gunConfig = gunConfig v.gunConfig = gunConfig
v.transport = gun.NewHTTP2Client(dialFn, tlsConfig, v.option.ClientFingerprint, v.echConfig, v.realityConfig) v.gunTransport = gun.NewHTTP2Client(dialFn, tlsConfig)
} }
return v, nil return v, nil

View File

@@ -34,9 +34,8 @@ type Vmess struct {
option *VmessOption option *VmessOption
// for gun mux // for gun mux
gunTLSConfig *tls.Config
gunConfig *gun.Config gunConfig *gun.Config
transport *gun.TransportWrap gunTransport *gun.TransportWrap
realityConfig *tlsC.RealityConfig realityConfig *tlsC.RealityConfig
echConfig *ech.Config echConfig *ech.Config
@@ -98,7 +97,6 @@ type WSOptions struct {
V2rayHttpUpgradeFastOpen bool `proxy:"v2ray-http-upgrade-fast-open,omitempty"` V2rayHttpUpgradeFastOpen bool `proxy:"v2ray-http-upgrade-fast-open,omitempty"`
} }
// StreamConnContext implements C.ProxyAdapter
func (v *Vmess) StreamConnContext(ctx context.Context, c net.Conn, metadata *C.Metadata) (_ net.Conn, err error) { func (v *Vmess) StreamConnContext(ctx context.Context, c net.Conn, metadata *C.Metadata) (_ net.Conn, err error) {
switch v.option.Network { switch v.option.Network {
case "ws": case "ws":
@@ -205,7 +203,7 @@ func (v *Vmess) StreamConnContext(ctx context.Context, c net.Conn, metadata *C.M
c, err = mihomoVMess.StreamH2Conn(ctx, c, h2Opts) c, err = mihomoVMess.StreamH2Conn(ctx, c, h2Opts)
case "grpc": case "grpc":
c, err = gun.StreamGunWithConn(c, v.gunTLSConfig, v.gunConfig, v.echConfig, v.realityConfig) break // already handle in gun transport
default: default:
// handle TLS // handle TLS
if v.option.TLS { if v.option.TLS {
@@ -296,23 +294,11 @@ func (v *Vmess) streamConnContext(ctx context.Context, c net.Conn, metadata *C.M
func (v *Vmess) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Conn, err error) { func (v *Vmess) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Conn, err error) {
var c net.Conn var c net.Conn
// gun transport // gun transport
if v.transport != nil { if v.gunTransport != nil {
c, err = gun.StreamGunWithTransport(v.transport, v.gunConfig) c, err = gun.StreamGunWithTransport(v.gunTransport, v.gunConfig)
if err != nil { } else {
return nil, err c, err = v.dialer.DialContext(ctx, "tcp", v.addr)
}
defer func(c net.Conn) {
safeConnClose(c, err)
}(c)
c, err = v.streamConnContext(ctx, c, metadata)
if err != nil {
return nil, err
}
return NewConn(c, v), nil
} }
c, err = v.dialer.DialContext(ctx, "tcp", v.addr)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s connect error: %s", v.addr, err.Error()) return nil, fmt.Errorf("%s connect error: %s", v.addr, err.Error())
} }
@@ -331,27 +317,11 @@ func (v *Vmess) ListenPacketContext(ctx context.Context, metadata *C.Metadata) (
} }
var c net.Conn var c net.Conn
// gun transport // gun transport
if v.transport != nil { if v.gunTransport != nil {
c, err = gun.StreamGunWithTransport(v.transport, v.gunConfig) c, err = gun.StreamGunWithTransport(v.gunTransport, v.gunConfig)
if err != nil { } else {
return nil, err c, err = v.dialer.DialContext(ctx, "tcp", v.addr)
}
defer func(c net.Conn) {
safeConnClose(c, err)
}(c)
c, err = v.streamConnContext(ctx, c, metadata)
if err != nil {
return nil, fmt.Errorf("new vmess client error: %v", err)
}
return v.ListenPacketOnStreamConn(ctx, c, metadata)
} }
if err = v.ResolveUDP(ctx, metadata); err != nil {
return nil, err
}
c, err = v.dialer.DialContext(ctx, "tcp", v.addr)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s connect error: %s", v.addr, err.Error()) return nil, fmt.Errorf("%s connect error: %s", v.addr, err.Error())
} }
@@ -375,8 +345,8 @@ func (v *Vmess) ProxyInfo() C.ProxyInfo {
// Close implements C.ProxyAdapter // Close implements C.ProxyAdapter
func (v *Vmess) Close() error { func (v *Vmess) Close() error {
if v.transport != nil { if v.gunTransport != nil {
return v.transport.Close() return v.gunTransport.Close()
} }
return nil return nil
} }
@@ -467,38 +437,35 @@ func NewVmess(option VmessOption) (*Vmess, error) {
} }
gunConfig := &gun.Config{ gunConfig := &gun.Config{
ServiceName: v.option.GrpcOpts.GrpcServiceName, ServiceName: option.GrpcOpts.GrpcServiceName,
UserAgent: v.option.GrpcOpts.GrpcUserAgent, UserAgent: option.GrpcOpts.GrpcUserAgent,
Host: v.option.ServerName, Host: option.ServerName,
ClientFingerprint: v.option.ClientFingerprint,
} }
if option.ServerName == "" { if option.ServerName == "" {
gunConfig.Host = v.addr gunConfig.Host = v.addr
} }
var tlsConfig *tls.Config var tlsConfig *mihomoVMess.TLSConfig
if option.TLS { if option.TLS {
tlsConfig, err = ca.GetTLSConfig(ca.Option{ tlsConfig = &mihomoVMess.TLSConfig{
TLSConfig: &tls.Config{ Host: option.ServerName,
InsecureSkipVerify: v.option.SkipCertVerify, SkipCertVerify: option.SkipCertVerify,
ServerName: v.option.ServerName, FingerPrint: option.Fingerprint,
}, Certificate: option.Certificate,
Fingerprint: v.option.Fingerprint, PrivateKey: option.PrivateKey,
Certificate: v.option.Certificate, ClientFingerprint: option.ClientFingerprint,
PrivateKey: v.option.PrivateKey, NextProtos: []string{"h2"},
}) ECH: v.echConfig,
if err != nil { Reality: v.realityConfig,
return nil, err
} }
if option.ServerName == "" { if option.ServerName == "" {
host, _, _ := net.SplitHostPort(v.addr) host, _, _ := net.SplitHostPort(v.addr)
tlsConfig.ServerName = host tlsConfig.Host = host
} }
} }
v.gunTLSConfig = tlsConfig
v.gunConfig = gunConfig v.gunConfig = gunConfig
v.transport = gun.NewHTTP2Client(dialFn, tlsConfig, v.option.ClientFingerprint, v.echConfig, v.realityConfig) v.gunTransport = gun.NewHTTP2Client(dialFn, tlsConfig)
} }
return v, nil return v, nil

View File

@@ -77,8 +77,8 @@ type WireGuardOption struct {
} }
type WireGuardPeerOption struct { type WireGuardPeerOption struct {
Server string `proxy:"server"` Server string `proxy:"server,omitempty"`
Port int `proxy:"port"` Port int `proxy:"port,omitempty"`
PublicKey string `proxy:"public-key,omitempty"` PublicKey string `proxy:"public-key,omitempty"`
PreSharedKey string `proxy:"pre-shared-key,omitempty"` PreSharedKey string `proxy:"pre-shared-key,omitempty"`
Reserved []uint8 `proxy:"reserved,omitempty"` Reserved []uint8 `proxy:"reserved,omitempty"`

View File

@@ -272,7 +272,7 @@ func (gb *GroupBase) onDialFailed(adapterType C.AdapterType, err error, fn func(
log.Debugln("ProxyGroup: %s failed count: %d", gb.Name(), gb.failedTimes) log.Debugln("ProxyGroup: %s failed count: %d", gb.Name(), gb.failedTimes)
if gb.failedTimes >= gb.maxFailedTimes { if gb.failedTimes >= gb.maxFailedTimes {
log.Warnln("because %s failed multiple times, active health check", gb.Name()) log.Warnln("because %s failed multiple times, activate health check", gb.Name())
fn() fn()
} }
} }

View File

@@ -42,10 +42,6 @@ type GroupCommonOption struct {
IncludeAllProviders bool `group:"include-all-providers,omitempty"` IncludeAllProviders bool `group:"include-all-providers,omitempty"`
Hidden bool `group:"hidden,omitempty"` Hidden bool `group:"hidden,omitempty"`
Icon string `group:"icon,omitempty"` Icon string `group:"icon,omitempty"`
// removed configs, only for error logging
Interface string `group:"interface-name,omitempty"`
RoutingMark int `group:"routing-mark,omitempty"`
} }
func ParseProxyGroup(config map[string]any, proxyMap map[string]C.Proxy, providersMap map[string]P.ProxyProvider, AllProxies []string, AllProviders []string) (C.ProxyAdapter, error) { func ParseProxyGroup(config map[string]any, proxyMap map[string]C.Proxy, providersMap map[string]P.ProxyProvider, AllProxies []string, AllProviders []string) (C.ProxyAdapter, error) {
@@ -62,12 +58,15 @@ func ParseProxyGroup(config map[string]any, proxyMap map[string]C.Proxy, provide
return nil, errFormat return nil, errFormat
} }
if groupOption.RoutingMark != 0 { if _, ok := config["routing-mark"]; ok {
log.Errorln("The group [%s] with routing-mark configuration was removed, please set it directly on the proxy instead", groupOption.Name) log.Errorln("The group [%s] with routing-mark configuration was removed, please set it directly on the proxy instead", groupOption.Name)
} }
if groupOption.Interface != "" { if _, ok := config["interface-name"]; ok {
log.Errorln("The group [%s] with interface-name configuration was removed, please set it directly on the proxy instead", groupOption.Name) log.Errorln("The group [%s] with interface-name configuration was removed, please set it directly on the proxy instead", groupOption.Name)
} }
if _, ok := config["dialer-proxy"]; ok {
log.Errorln("The group [%s] with dialer-proxy configuration is not allowed, please set it directly on the proxy instead", groupOption.Name)
}
groupName := groupOption.Name groupName := groupOption.Name

View File

@@ -166,6 +166,13 @@ func ParseProxy(mapping map[string]any, options ...ProxyOption) (C.Proxy, error)
break break
} }
proxy, err = outbound.NewMasque(*masqueOption) proxy, err = outbound.NewMasque(*masqueOption)
case "trusttunnel":
trustTunnelOption := &outbound.TrustTunnelOption{BasicOption: basicOption}
err = decoder.Decode(mapping, trustTunnelOption)
if err != nil {
break
}
proxy, err = outbound.NewTrustTunnel(*trustTunnelOption)
default: default:
return nil, fmt.Errorf("unsupport proxy type: %s", proxyType) return nil, fmt.Errorf("unsupport proxy type: %s", proxyType)
} }

View File

@@ -60,7 +60,7 @@ func (o *overrideSchema) Apply(mapping map[string]any) error {
mapping["skip-cert-verify"] = *o.SkipCertVerify mapping["skip-cert-verify"] = *o.SkipCertVerify
} }
if o.Interface != nil { if o.Interface != nil {
mapping["interface"] = *o.Interface mapping["interface-name"] = *o.Interface
} }
if o.RoutingMark != nil { if o.RoutingMark != nil {
mapping["routing-mark"] = *o.RoutingMark mapping["routing-mark"] = *o.RoutingMark

View File

@@ -1,7 +1,6 @@
package buf package buf
import ( import (
"github.com/metacubex/sing/common"
"github.com/metacubex/sing/common/buf" "github.com/metacubex/sing/common/buf"
) )
@@ -9,14 +8,52 @@ const BufferSize = buf.BufferSize
type Buffer = buf.Buffer type Buffer = buf.Buffer
var New = buf.New func New() *Buffer {
var NewPacket = buf.NewPacket return buf.New()
var NewSize = buf.NewSize }
var With = buf.With
var As = buf.As
var ReleaseMulti = buf.ReleaseMulti
var ( func NewPacket() *Buffer {
Must = common.Must return buf.NewPacket()
Error = common.Error }
)
func NewSize(size int) *Buffer {
return buf.NewSize(size)
}
func With(data []byte) *Buffer {
return buf.With(data)
}
func As(data []byte) *Buffer {
return buf.As(data)
}
func ReleaseMulti(buffers []*Buffer) {
buf.ReleaseMulti(buffers)
}
func Error(_ any, err error) error {
return err
}
func Must(errs ...error) {
for _, err := range errs {
if err != nil {
panic(err)
}
}
}
func Must1[T any](result T, err error) T {
if err != nil {
panic(err)
}
return result
}
func Must2[T any, T2 any](result T, result2 T2, err error) (T, T2) {
if err != nil {
panic(err)
}
return result, result2
}

View File

@@ -7,6 +7,7 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"reflect" "reflect"
"sort"
"strconv" "strconv"
"strings" "strings"
) )
@@ -38,58 +39,7 @@ func (d *Decoder) Decode(src map[string]any, dst any) error {
if reflect.TypeOf(dst).Kind() != reflect.Ptr { if reflect.TypeOf(dst).Kind() != reflect.Ptr {
return fmt.Errorf("decode must recive a ptr struct") return fmt.Errorf("decode must recive a ptr struct")
} }
t := reflect.TypeOf(dst).Elem() return d.decode("", src, reflect.ValueOf(dst).Elem())
v := reflect.ValueOf(dst).Elem()
for idx := 0; idx < v.NumField(); idx++ {
field := t.Field(idx)
if field.Anonymous {
if err := d.decodeStruct(field.Name, src, v.Field(idx)); err != nil {
return err
}
continue
}
tag := field.Tag.Get(d.option.TagName)
key, omitKey, found := strings.Cut(tag, ",")
omitempty := found && omitKey == "omitempty"
// As a special case, if the field tag is "-", the field is always omitted.
// Note that a field with name "-" can still be generated using the tag "-,".
if key == "-" {
continue
}
value, ok := src[key]
if !ok {
if d.option.KeyReplacer != nil {
key = d.option.KeyReplacer.Replace(key)
}
for _strKey := range src {
strKey := _strKey
if d.option.KeyReplacer != nil {
strKey = d.option.KeyReplacer.Replace(strKey)
}
if strings.EqualFold(key, strKey) {
value = src[_strKey]
ok = true
break
}
}
}
if !ok || value == nil {
if omitempty {
continue
}
return fmt.Errorf("key '%s' missing", key)
}
err := d.decode(key, value, v.Field(idx))
if err != nil {
return err
}
}
return nil
} }
// isNil returns true if the input is nil or a typed nil pointer. // isNil returns true if the input is nil or a typed nil pointer.
@@ -456,6 +406,7 @@ func (d *Decoder) decodeStructFromMap(name string, dataVal, val reflect.Value) e
dataValKeysUnused[dataValKey.Interface()] = struct{}{} dataValKeysUnused[dataValKey.Interface()] = struct{}{}
} }
targetValKeysUnused := make(map[any]struct{})
errors := make([]string, 0) errors := make([]string, 0)
// This slice will keep track of all the structs we'll be decoding. // This slice will keep track of all the structs we'll be decoding.
@@ -470,6 +421,11 @@ func (d *Decoder) decodeStructFromMap(name string, dataVal, val reflect.Value) e
field reflect.StructField field reflect.StructField
val reflect.Value val reflect.Value
} }
// remainField is set to a valid field set with the "remain" tag if
// we are keeping track of remaining values.
var remainField *field
var fields []field var fields []field
for len(structs) > 0 { for len(structs) > 0 {
structVal := structs[0] structVal := structs[0]
@@ -479,30 +435,47 @@ func (d *Decoder) decodeStructFromMap(name string, dataVal, val reflect.Value) e
for i := 0; i < structType.NumField(); i++ { for i := 0; i < structType.NumField(); i++ {
fieldType := structType.Field(i) fieldType := structType.Field(i)
fieldKind := fieldType.Type.Kind() fieldVal := structVal.Field(i)
if fieldVal.Kind() == reflect.Ptr && fieldVal.Elem().Kind() == reflect.Struct {
// Handle embedded struct pointers as embedded structs.
fieldVal = fieldVal.Elem()
}
// If "squash" is specified in the tag, we squash the field down. // If "squash" is specified in the tag, we squash the field down.
squash := false squash := fieldVal.Kind() == reflect.Struct && fieldType.Anonymous
remain := false
// We always parse the tags cause we're looking for other tags too
tagParts := strings.Split(fieldType.Tag.Get(d.option.TagName), ",") tagParts := strings.Split(fieldType.Tag.Get(d.option.TagName), ",")
for _, tag := range tagParts[1:] { for _, tag := range tagParts[1:] {
if tag == "squash" { if tag == "squash" {
squash = true squash = true
break break
} }
if tag == "remain" {
remain = true
break
}
} }
if squash { if squash {
if fieldKind != reflect.Struct { if fieldVal.Kind() != reflect.Struct {
errors = append(errors, errors = append(errors,
fmt.Errorf("%s: unsupported type for squash: %s", fieldType.Name, fieldKind).Error()) fmt.Errorf("%s: unsupported type for squash: %s", fieldType.Name, fieldVal.Kind()).Error())
} else { } else {
structs = append(structs, structVal.FieldByName(fieldType.Name)) structs = append(structs, fieldVal)
} }
continue continue
} }
// Normal struct field, store it away // Build our field
fields = append(fields, field{fieldType, structVal.Field(i)}) if remain {
remainField = &field{fieldType, fieldVal}
} else {
// Normal struct field, store it away
fields = append(fields, field{fieldType, fieldVal})
}
} }
} }
@@ -511,8 +484,8 @@ func (d *Decoder) decodeStructFromMap(name string, dataVal, val reflect.Value) e
field, fieldValue := f.field, f.val field, fieldValue := f.field, f.val
fieldName := field.Name fieldName := field.Name
tagValue := field.Tag.Get(d.option.TagName) tagParts := strings.Split(field.Tag.Get(d.option.TagName), ",")
tagValue = strings.SplitN(tagValue, ",", 2)[0] tagValue := tagParts[0]
if tagValue != "" { if tagValue != "" {
fieldName = tagValue fieldName = tagValue
} }
@@ -521,6 +494,13 @@ func (d *Decoder) decodeStructFromMap(name string, dataVal, val reflect.Value) e
continue continue
} }
omitempty := false
for _, tag := range tagParts[1:] {
if tag == "omitempty" {
omitempty = true
}
}
rawMapKey := reflect.ValueOf(fieldName) rawMapKey := reflect.ValueOf(fieldName)
rawMapVal := dataVal.MapIndex(rawMapKey) rawMapVal := dataVal.MapIndex(rawMapKey)
if !rawMapVal.IsValid() { if !rawMapVal.IsValid() {
@@ -548,7 +528,10 @@ func (d *Decoder) decodeStructFromMap(name string, dataVal, val reflect.Value) e
if !rawMapVal.IsValid() { if !rawMapVal.IsValid() {
// There was no matching key in the map for the value in // There was no matching key in the map for the value in
// the struct. Just ignore. // the struct. Remember it for potential errors and metadata.
if !omitempty {
targetValKeysUnused[fieldName] = struct{}{}
}
continue continue
} }
} }
@@ -570,7 +553,7 @@ func (d *Decoder) decodeStructFromMap(name string, dataVal, val reflect.Value) e
// If the name is empty string, then we're at the root, and we // If the name is empty string, then we're at the root, and we
// don't dot-join the fields. // don't dot-join the fields.
if name != "" { if name != "" {
fieldName = fmt.Sprintf("%s.%s", name, fieldName) fieldName = name + "." + fieldName
} }
if err := d.decode(fieldName, rawMapVal.Interface(), fieldValue); err != nil { if err := d.decode(fieldName, rawMapVal.Interface(), fieldValue); err != nil {
@@ -578,6 +561,36 @@ func (d *Decoder) decodeStructFromMap(name string, dataVal, val reflect.Value) e
} }
} }
// If we have a "remain"-tagged field and we have unused keys then
// we put the unused keys directly into the remain field.
if remainField != nil && len(dataValKeysUnused) > 0 {
// Build a map of only the unused values
remain := map[interface{}]interface{}{}
for key := range dataValKeysUnused {
remain[key] = dataVal.MapIndex(reflect.ValueOf(key)).Interface()
}
// Decode it as-if we were just decoding this map onto our map.
if err := d.decodeMap(name, remain, remainField.val); err != nil {
errors = append(errors, err.Error())
}
// Set the map to nil so we have none so that the next check will
// not error (ErrorUnused)
dataValKeysUnused = nil
}
if len(targetValKeysUnused) > 0 {
keys := make([]string, 0, len(targetValKeysUnused))
for rawKey := range targetValKeysUnused {
keys = append(keys, rawKey.(string))
}
sort.Strings(keys)
err := fmt.Errorf("'%s' has unset fields: %s", name, strings.Join(keys, ", "))
errors = append(errors, err.Error())
}
if len(errors) > 0 { if len(errors) > 0 {
return fmt.Errorf(strings.Join(errors, ",")) return fmt.Errorf(strings.Join(errors, ","))
} }

View File

@@ -139,6 +139,49 @@ func TestStructure_Nest(t *testing.T) {
assert.Equal(t, s.BazOptional, goal) assert.Equal(t, s.BazOptional, goal)
} }
func TestStructure_DoubleNest(t *testing.T) {
rawMap := map[string]any{
"bar": map[string]any{
"foo": 1,
},
}
goal := BazOptional{
Foo: 1,
}
s := &struct {
Bar struct {
BazOptional
} `test:"bar"`
}{}
err := decoder.Decode(rawMap, s)
assert.Nil(t, err)
assert.Equal(t, s.Bar.BazOptional, goal)
}
func TestStructure_Remain(t *testing.T) {
rawMap := map[string]any{
"foo": 1,
"bar": "test",
"extra": false,
}
goal := &Baz{
Foo: 1,
Bar: "test",
}
s := &struct {
Baz
Remain map[string]any `test:",remain"`
}{}
err := decoder.Decode(rawMap, s)
assert.Nil(t, err)
assert.Equal(t, *goal, s.Baz)
assert.Equal(t, map[string]any{"extra": false}, s.Remain)
}
func TestStructure_SliceNilValue(t *testing.T) { func TestStructure_SliceNilValue(t *testing.T) {
rawMap := map[string]any{ rawMap := map[string]any{
"foo": 1, "foo": 1,
@@ -228,6 +271,23 @@ func TestStructure_Pointer(t *testing.T) {
assert.Nil(t, s.Bar) assert.Nil(t, s.Bar)
} }
func TestStructure_PointerStruct(t *testing.T) {
rawMap := map[string]any{
"foo": "foo",
}
s := &struct {
Foo *string `test:"foo,omitempty"`
Bar *Baz `test:"bar,omitempty"`
}{}
err := decoder.Decode(rawMap, s)
assert.Nil(t, err)
assert.NotNil(t, s.Foo)
assert.Equal(t, "foo", *s.Foo)
assert.Nil(t, s.Bar)
}
type num struct { type num struct {
a int a int
} }

View File

@@ -50,7 +50,7 @@ func HttpRequest(ctx context.Context, url, method string, header map[string][]st
} }
} }
if _, ok := header["User-Agent"]; !ok { if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", UA()) req.Header.Set("User-Agent", UA())
} }

View File

@@ -135,6 +135,8 @@ func UEncryptedClientHelloKey(it tls.EncryptedClientHelloKey) utls.EncryptedClie
} }
} }
type ConnectionState = utls.ConnectionState
type Config = utls.Config type Config = utls.Config
var tlsCertificateRequestInfoCtxOffset = utils.MustOK(reflect.TypeOf((*tls.CertificateRequestInfo)(nil)).Elem().FieldByName("ctx")).Offset var tlsCertificateRequestInfoCtxOffset = utils.MustOK(reflect.TypeOf((*tls.CertificateRequestInfo)(nil)).Elem().FieldByName("ctx")).Offset

View File

@@ -46,6 +46,7 @@ const (
AnyTLS AnyTLS
Sudoku Sudoku
Masque Masque
TrustTunnel
) )
const ( const (
@@ -215,6 +216,8 @@ func (at AdapterType) String() string {
return "Sudoku" return "Sudoku"
case Masque: case Masque:
return "Masque" return "Masque"
case TrustTunnel:
return "TrustTunnel"
case Relay: case Relay:
return "Relay" return "Relay"
case Selector: case Selector:

View File

@@ -40,6 +40,7 @@ const (
ANYTLS ANYTLS
MIERU MIERU
SUDOKU SUDOKU
TRUSTTUNNEL
INNER INNER
) )
@@ -115,6 +116,8 @@ func (t Type) String() string {
return "Mieru" return "Mieru"
case SUDOKU: case SUDOKU:
return "Sudoku" return "Sudoku"
case TRUSTTUNNEL:
return "TrustTunnel"
case INNER: case INNER:
return "Inner" return "Inner"
default: default:
@@ -159,6 +162,8 @@ func ParseType(t string) (*Type, error) {
res = MIERU res = MIERU
case "SUDOKU": case "SUDOKU":
res = SUDOKU res = SUDOKU
case "TRUSTTUNNEL":
res = TRUSTTUNNEL
case "INNER": case "INNER":
res = INNER res = INNER
default: default:

View File

@@ -23,6 +23,7 @@ type dnsOverTLS struct {
host string host string
dialer *dnsDialer dialer *dnsDialer
skipCertVerify bool skipCertVerify bool
disableReuse bool
access sync.Mutex access sync.Mutex
connections deque.Deque[net.Conn] // LIFO connections deque.Deque[net.Conn] // LIFO
@@ -57,11 +58,13 @@ func (t *dnsOverTLS) ExchangeContext(ctx context.Context, m *D.Msg) (*D.Msg, err
var conn net.Conn var conn net.Conn
isOldConn := true isOldConn := true
t.access.Lock() if !t.disableReuse {
if t.connections.Len() > 0 { t.access.Lock()
conn = t.connections.PopBack() if t.connections.Len() > 0 {
conn = t.connections.PopBack()
}
t.access.Unlock()
} }
t.access.Unlock()
if conn == nil { if conn == nil {
conn, err = t.dialContext(ctx) conn, err = t.dialContext(ctx)
@@ -90,13 +93,17 @@ func (t *dnsOverTLS) ExchangeContext(ctx context.Context, m *D.Msg) (*D.Msg, err
return return
} }
t.access.Lock() if !t.disableReuse {
if t.connections.Len() >= maxOldDotConns { t.access.Lock()
oldConn := t.connections.PopFront() if t.connections.Len() >= maxOldDotConns {
go oldConn.Close() // close in a new goroutine, not blocking the current task oldConn := t.connections.PopFront()
go oldConn.Close() // close in a new goroutine, not blocking the current task
}
t.connections.PushBack(conn)
t.access.Unlock()
} else {
_ = conn.Close()
} }
t.connections.PushBack(conn)
t.access.Unlock()
return return
} }
}() }()
@@ -134,12 +141,14 @@ func (t *dnsOverTLS) dialContext(ctx context.Context) (net.Conn, error) {
} }
func (t *dnsOverTLS) ResetConnection() { func (t *dnsOverTLS) ResetConnection() {
t.access.Lock() if !t.disableReuse {
for t.connections.Len() > 0 { t.access.Lock()
oldConn := t.connections.PopFront() for t.connections.Len() > 0 {
go oldConn.Close() // close in a new goroutine, not blocking the current task oldConn := t.connections.PopFront()
go oldConn.Close() // close in a new goroutine, not blocking the current task
}
t.access.Unlock()
} }
t.access.Unlock()
} }
func (t *dnsOverTLS) Close() error { func (t *dnsOverTLS) Close() error {
@@ -159,6 +168,9 @@ func newDoTClient(addr string, resolver *Resolver, params map[string]string, pro
if params["skip-cert-verify"] == "true" { if params["skip-cert-verify"] == "true" {
c.skipCertVerify = true c.skipCertVerify = true
} }
if params["disable-reuse"] == "true" {
c.disableReuse = true
}
runtime.SetFinalizer(c, (*dnsOverTLS).Close) runtime.SetFinalizer(c, (*dnsOverTLS).Close)
return c return c
} }

View File

@@ -1077,6 +1077,8 @@ proxies: # socks5
# multiplexing: MULTIPLEXING_LOW # multiplexing: MULTIPLEXING_LOW
# 如果想开启 0-RTT 握手,请设置为 HANDSHAKE_NO_WAIT否则请设置为 HANDSHAKE_STANDARD。默认值为 HANDSHAKE_STANDARD # 如果想开启 0-RTT 握手,请设置为 HANDSHAKE_NO_WAIT否则请设置为 HANDSHAKE_STANDARD。默认值为 HANDSHAKE_STANDARD
# handshake-mode: HANDSHAKE_STANDARD # handshake-mode: HANDSHAKE_STANDARD
# 一个 base64 字符串用于微调网络行为
# traffic-pattern: ""
# sudoku # sudoku
- name: sudoku - name: sudoku
@@ -1090,12 +1092,22 @@ proxies: # socks5
table-type: prefer_ascii # 可选值prefer_ascii、prefer_entropy 前者全ascii映射后者保证熵值汉明1低于3 table-type: prefer_ascii # 可选值prefer_ascii、prefer_entropy 前者全ascii映射后者保证熵值汉明1低于3
# custom-table: xpxvvpvv # 可选自定义字节布局必须包含2个x、2个p、4个v可随意组合。启用此处则需配置`table-type`为`prefer_entropy` # custom-table: xpxvvpvv # 可选自定义字节布局必须包含2个x、2个p、4个v可随意组合。启用此处则需配置`table-type`为`prefer_entropy`
# custom-tables: ["xpxvvpvv", "vxpvxvvp"] # 可选自定义字节布局列表x/v/p用于 xvp 模式轮换;非空时覆盖 custom-table # custom-tables: ["xpxvvpvv", "vxpvxvvp"] # 可选自定义字节布局列表x/v/p用于 xvp 模式轮换;非空时覆盖 custom-table
http-mask: true # 是否启用http掩码 # 推荐:使用 httpmask 对象统一管理 HTTPMask 相关字段:
# http-mask-mode: legacy # 可选legacy默认、streamsplit-stream、poll、auto先 stream 再 pollstream/poll/auto 支持走 CDN/反代 httpmask:
# http-mask-tls: true # 可选:仅在 http-mask-mode 为 stream/poll/auto 时生效true 强制 httpsfalse 强制 http不会根据端口自动推断 disable: false # true 禁用所有 HTTP 伪装/隧道
# http-mask-host: "" # 可选:覆盖 Host/SNI支持 example.com 或 example.com:443仅在 http-mask-mode 为 stream/poll/auto 时生效 mode: legacy # 可选legacy默认、streamsplit-stream、poll、auto streampoll、wsWebSocket 隧道)
# path-root: "" # 可选HTTP 隧道端点一级路径前缀(双方需一致),例如 "aabbcc" 或 "/aabbcc/" => /aabbcc/session、/aabbcc/stream、/aabbcc/api/v1/upload # tls: true # 可选:仅在 mode 为 stream/poll/auto/ws 时生效true 强制 https/wssfalse 强制 http/ws不会根据端口自动推断
# http-mask-multiplex: off # 可选off默认、auto复用底层 HTTP 连接,减少建链 RTT、onSudoku mux 单隧道多目标;仅在 http-mask-mode=stream/poll/auto 生效 # host: "" # 可选:覆盖 Host/SNI支持 example.com 或 example.com:443仅在 modestream/poll/auto/ws 时生效
# path_root: "" # 可选HTTP 隧道端点一级路径前缀(双方需一致),例如 "aabbcc" 或 "/aabbcc/" => /aabbcc/session、/aabbcc/stream、/aabbcc/api/v1/upload、/aabbcc/ws
# multiplex: off # 可选off默认、auto复用底层 HTTP 连接,减少建链 RTT、onSudoku mux 单隧道多目标;仅在 mode=stream/poll/auto 生效ws 强制 off
#
# 向后兼容旧写法:
# http-mask: true # 是否启用 http 掩码
# http-mask-mode: legacy # 可选legacy默认、streamsplit-stream、poll、auto先 stream 再 poll、wsWebSocket 隧道)
# http-mask-tls: true # 可选:仅在 http-mask-mode 为 stream/poll/auto/ws 时生效true 强制 https/wssfalse 强制 http/ws
# http-mask-host: "" # 可选:覆盖 Host/SNI支持 example.com 或 example.com:443仅在 http-mask-mode 为 stream/poll/auto/ws 时生效
# path-root: "" # 可选HTTP 隧道端点一级路径前缀(双方需一致)
# http-mask-multiplex: off # 可选off默认、auto复用底层 HTTP 连接、onSudoku mux 单隧道多目标ws 强制 off
enable-pure-downlink: false # 可选false=带宽优化下行(更快,要求 aead-method != nonetrue=纯 Sudoku 下行 enable-pure-downlink: false # 可选false=带宽优化下行(更快,要求 aead-method != nonetrue=纯 Sudoku 下行
# anytls # anytls
@@ -1115,6 +1127,23 @@ proxies: # socks5
# - http/1.1 # - http/1.1
# skip-cert-verify: true # skip-cert-verify: true
# trusttunnel
- name: trusttunnel
type: trusttunnel
server: 1.2.3.4
port: 443
username: username
password: password
# client-fingerprint: chrome
health-check: true
udp: true
# sni: "example.com"
# alpn:
# - h2
# skip-cert-verify: true
# quic: true # 默认为false
# congestion-controller: bbr
# dns 出站会将请求劫持到内部 dns 模块,所有请求均在内部处理 # dns 出站会将请求劫持到内部 dns 模块,所有请求均在内部处理
- name: "dns-out" - name: "dns-out"
type: dns type: dns
@@ -1628,6 +1657,8 @@ listeners:
users: users:
username1: password1 username1: password1
username2: password2 username2: password2
# 一个 base64 字符串用于微调网络行为
# traffic-pattern: ""
- name: sudoku-in-1 - name: sudoku-in-1
type: sudoku type: sudoku
@@ -1642,9 +1673,19 @@ listeners:
# custom-tables: ["xpxvvpvv", "vxpvxvvp"] # 可选自定义字节布局列表x/v/p用于 xvp 模式轮换;非空时覆盖 custom-table # custom-tables: ["xpxvvpvv", "vxpvxvvp"] # 可选自定义字节布局列表x/v/p用于 xvp 模式轮换;非空时覆盖 custom-table
handshake-timeout: 5 # 可选(秒) handshake-timeout: 5 # 可选(秒)
enable-pure-downlink: false # 可选false=带宽优化下行(更快,要求 aead-method != nonetrue=纯 Sudoku 下行 enable-pure-downlink: false # 可选false=带宽优化下行(更快,要求 aead-method != nonetrue=纯 Sudoku 下行
disable-http-mask: false # 可选:禁用 http 掩码/隧道(默认为 false # 推荐:使用 httpmask 对象统一管理 HTTPMask 相关字段:
# http-mask-mode: legacy # 可选legacy默认、streamsplit-stream、poll、auto先 stream 再 pollstream/poll/auto 支持走 CDN/反代 httpmask:
# path-root: "" # 可选HTTP 隧道端点一级路径前缀(双方需一致),例如 "aabbcc" 或 "/aabbcc/" => /aabbcc/session、/aabbcc/stream、/aabbcc/api/v1/upload disable: false # true 禁用所有 HTTP 伪装/隧道
mode: legacy # 可选legacy默认、streamsplit-stream、poll、auto先 stream 再 poll、wsWebSocket 隧道)
# path_root: "" # 可选HTTP 隧道端点一级路径前缀(双方需一致),例如 "aabbcc" 或 "/aabbcc/" => /aabbcc/session、/aabbcc/stream、/aabbcc/api/v1/upload、/aabbcc/ws
#
# 可选:当启用 HTTPMask 且识别到“像 HTTP 但不符合 tunnel/auth”的请求时将原始字节透传给 fallback常用于与其他服务共端口
# fallback: "127.0.0.1:80"
#
# 向后兼容旧写法:
# disable-http-mask: false # 可选:禁用 http 掩码/隧道(默认为 false
# http-mask-mode: legacy # 可选legacy默认、streamsplit-stream、poll、auto先 stream 再 poll、wsWebSocket 隧道)
# path-root: "" # 可选HTTP 隧道端点一级路径前缀(双方需一致)
@@ -1731,6 +1772,30 @@ listeners:
# masquerade: http://127.0.0.1:8080 #作为反向代理 # masquerade: http://127.0.0.1:8080 #作为反向代理
# masquerade: https://127.0.0.1:8080 #作为反向代理 # masquerade: https://127.0.0.1:8080 #作为反向代理
- name: trusttunnel-in-1
type: trusttunnel
port: 10821 # 支持使用ports格式例如200,302 or 200,204,401-429,501-503
listen: 0.0.0.0
# rule: sub-rule-name1 # 默认使用 rules如果未找到 sub-rule 则直接使用 rules
# proxy: proxy # 如果不为空则直接将该入站流量交由指定 proxy 处理 (当 proxy 不为空时,这里的 proxy 名称必须合法,否则会出错)
users:
- username: 1
password: 9d0cb9d0-964f-4ef6-897d-6c6b3ccf9e68
certificate: ./server.crt # 证书 PEM 格式,或者 证书的路径
private-key: ./server.key # 证书对应的私钥 PEM 格式,或者私钥路径
network: ["tcp", "udp"] # http2+http3
congestion-controller: bbr
# 下面两项为mTLS配置项如果client-auth-type设置为 "verify-if-given" 或 "require-and-verify" 则client-auth-cert必须不为空
# client-auth-type: "" # 可选值:""、"request"、"require-any"、"verify-if-given"、"require-and-verify"
# client-auth-cert: string # 证书 PEM 格式,或者 证书的路径
# 如果填写则开启ech可由 mihomo generate ech-keypair <明文域名> 生成)
# ech-key: |
# -----BEGIN ECH KEYS-----
# ACATwY30o/RKgD6hgeQxwrSiApLaCgU+HKh7B6SUrAHaDwBD/g0APwAAIAAgHjzK
# madSJjYQIf9o1N5GXjkW4DEEeb17qMxHdwMdNnwADAABAAEAAQACAAEAAwAIdGVz
# dC5jb20AAA==
# -----END ECH KEYS-----
# 注意listeners中的tun仅提供给高级用户使用普通用户应使用顶层配置中的tun # 注意listeners中的tun仅提供给高级用户使用普通用户应使用顶层配置中的tun
- name: tun-in-1 - name: tun-in-1
type: tun type: tun

8
go.mod
View File

@@ -3,11 +3,10 @@ module github.com/metacubex/mihomo
go 1.20 go 1.20
require ( require (
filippo.io/edwards25519 v1.1.0
github.com/bahlo/generic-list-go v0.2.0 github.com/bahlo/generic-list-go v0.2.0
github.com/coreos/go-iptables v0.8.0 github.com/coreos/go-iptables v0.8.0
github.com/dlclark/regexp2 v1.11.5 github.com/dlclark/regexp2 v1.11.5
github.com/enfein/mieru/v3 v3.26.2 github.com/enfein/mieru/v3 v3.28.0
github.com/gobwas/ws v1.4.0 github.com/gobwas/ws v1.4.0
github.com/gofrs/uuid/v5 v5.4.0 github.com/gofrs/uuid/v5 v5.4.0
github.com/golang/snappy v1.0.0 github.com/golang/snappy v1.0.0
@@ -19,12 +18,13 @@ require (
github.com/metacubex/chi v0.1.0 github.com/metacubex/chi v0.1.0
github.com/metacubex/connect-ip-go v0.0.0-20260128031117-1cad62060727 github.com/metacubex/connect-ip-go v0.0.0-20260128031117-1cad62060727
github.com/metacubex/cpu v0.1.0 github.com/metacubex/cpu v0.1.0
github.com/metacubex/edwards25519 v1.2.0
github.com/metacubex/fswatch v0.1.1 github.com/metacubex/fswatch v0.1.1
github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759
github.com/metacubex/http v0.1.0 github.com/metacubex/http v0.1.0
github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604 github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604
github.com/metacubex/mlkem v0.1.0 github.com/metacubex/mlkem v0.1.0
github.com/metacubex/quic-go v0.59.1-0.20260128071132-0f3233b973af github.com/metacubex/quic-go v0.59.1-0.20260213014310-4df8f0de5b56
github.com/metacubex/randv2 v0.2.0 github.com/metacubex/randv2 v0.2.0
github.com/metacubex/restls-client-go v0.1.7 github.com/metacubex/restls-client-go v0.1.7
github.com/metacubex/sing v0.5.7 github.com/metacubex/sing v0.5.7
@@ -33,7 +33,7 @@ require (
github.com/metacubex/sing-shadowsocks v0.2.12 github.com/metacubex/sing-shadowsocks v0.2.12
github.com/metacubex/sing-shadowsocks2 v0.2.7 github.com/metacubex/sing-shadowsocks2 v0.2.7
github.com/metacubex/sing-shadowtls v0.0.0-20250503063515-5d9f966d17a2 github.com/metacubex/sing-shadowtls v0.0.0-20250503063515-5d9f966d17a2
github.com/metacubex/sing-tun v0.4.15 github.com/metacubex/sing-tun v0.4.16-0.20260213034958-b09e3f5bbae6
github.com/metacubex/sing-vmess v0.2.5 github.com/metacubex/sing-vmess v0.2.5
github.com/metacubex/sing-wireguard v0.0.0-20250503063753-2dc62acc626f github.com/metacubex/sing-wireguard v0.0.0-20250503063753-2dc62acc626f
github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141 github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141

16
go.sum
View File

@@ -1,5 +1,3 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/RyuaNerin/go-krypto v1.3.0 h1:smavTzSMAx8iuVlGb4pEwl9MD2qicqMzuXR2QWp2/Pg= github.com/RyuaNerin/go-krypto v1.3.0 h1:smavTzSMAx8iuVlGb4pEwl9MD2qicqMzuXR2QWp2/Pg=
github.com/RyuaNerin/go-krypto v1.3.0/go.mod h1:9R9TU936laAIqAmjcHo/LsaXYOZlymudOAxjaBf62UM= github.com/RyuaNerin/go-krypto v1.3.0/go.mod h1:9R9TU936laAIqAmjcHo/LsaXYOZlymudOAxjaBf62UM=
github.com/RyuaNerin/testingutil v0.1.0 h1:IYT6JL57RV3U2ml3dLHZsVtPOP6yNK7WUVdzzlpNrss= github.com/RyuaNerin/testingutil v0.1.0 h1:IYT6JL57RV3U2ml3dLHZsVtPOP6yNK7WUVdzzlpNrss=
@@ -22,8 +20,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0= github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0=
github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
github.com/enfein/mieru/v3 v3.26.2 h1:U/2XJc+3vrJD9r815FoFdwToQFEcqSOzzzWIPPhjfEU= github.com/enfein/mieru/v3 v3.28.0 h1:4OsFPUIjKfQ6ymfyX1Laqz7h+zB8TxuK1m0isnYJ8ww=
github.com/enfein/mieru/v3 v3.26.2/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= github.com/enfein/mieru/v3 v3.28.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 h1:kXYqH/sL8dS/FdoFjr12ePjnLPorPo2FsnrHNuXSDyo= github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 h1:kXYqH/sL8dS/FdoFjr12ePjnLPorPo2FsnrHNuXSDyo=
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358/go.mod h1:hkIFzoiIPZYxdFOOLyDho59b7SrDfo+w3h+yWdlg45I= github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358/go.mod h1:hkIFzoiIPZYxdFOOLyDho59b7SrDfo+w3h+yWdlg45I=
github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 h1:8j2RH289RJplhA6WfdaPqzg1MjH2K8wX5e0uhAxrw2g= github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 h1:8j2RH289RJplhA6WfdaPqzg1MjH2K8wX5e0uhAxrw2g=
@@ -93,6 +91,8 @@ github.com/metacubex/connect-ip-go v0.0.0-20260128031117-1cad62060727 h1:qbZQ0sO
github.com/metacubex/connect-ip-go v0.0.0-20260128031117-1cad62060727/go.mod h1:xYC8Ik7/rN6no+vTRuWMEziGwm3brA0wNM/zZP9qhOQ= github.com/metacubex/connect-ip-go v0.0.0-20260128031117-1cad62060727/go.mod h1:xYC8Ik7/rN6no+vTRuWMEziGwm3brA0wNM/zZP9qhOQ=
github.com/metacubex/cpu v0.1.0 h1:8PeTdV9j6UKbN1K5Jvtbi/Jock7dknvzyYuLb8Conmk= github.com/metacubex/cpu v0.1.0 h1:8PeTdV9j6UKbN1K5Jvtbi/Jock7dknvzyYuLb8Conmk=
github.com/metacubex/cpu v0.1.0/go.mod h1:09VEt4dSRLR+bOA8l4w4NDuzGZ8n5dkMv7e8axgEeTU= github.com/metacubex/cpu v0.1.0/go.mod h1:09VEt4dSRLR+bOA8l4w4NDuzGZ8n5dkMv7e8axgEeTU=
github.com/metacubex/edwards25519 v1.2.0 h1:pIQZLBsjQgg3Nl/c86YYFEUAbL5qQRnPq4LrgIw0KK4=
github.com/metacubex/edwards25519 v1.2.0/go.mod h1:NCQF3J/Ki7382FJuokwsywEIIEI/gro/3smyXgQJsx0=
github.com/metacubex/fswatch v0.1.1 h1:jqU7C/v+g0qc2RUFgmAOPoVvfl2BXXUXEumn6oQuxhU= github.com/metacubex/fswatch v0.1.1 h1:jqU7C/v+g0qc2RUFgmAOPoVvfl2BXXUXEumn6oQuxhU=
github.com/metacubex/fswatch v0.1.1/go.mod h1:czrTT7Zlbz7vWft8RQu9Qqh+JoX+Nnb+UabuyN1YsgI= github.com/metacubex/fswatch v0.1.1/go.mod h1:czrTT7Zlbz7vWft8RQu9Qqh+JoX+Nnb+UabuyN1YsgI=
github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 h1:cjd4biTvOzK9ubNCCkQ+ldc4YSH/rILn53l/xGBFHHI= github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 h1:cjd4biTvOzK9ubNCCkQ+ldc4YSH/rILn53l/xGBFHHI=
@@ -113,8 +113,8 @@ github.com/metacubex/nftables v0.0.0-20250503052935-30a69ab87793 h1:1Qpuy+sU3Dmy
github.com/metacubex/nftables v0.0.0-20250503052935-30a69ab87793/go.mod h1:RjRNb4G52yAgfR+Oe/kp9G4PJJ97Fnj89eY1BFO3YyA= github.com/metacubex/nftables v0.0.0-20250503052935-30a69ab87793/go.mod h1:RjRNb4G52yAgfR+Oe/kp9G4PJJ97Fnj89eY1BFO3YyA=
github.com/metacubex/qpack v0.6.0 h1:YqClGIMOpiRYLjV1qOs483Od08MdPgRnHjt90FuaAKw= github.com/metacubex/qpack v0.6.0 h1:YqClGIMOpiRYLjV1qOs483Od08MdPgRnHjt90FuaAKw=
github.com/metacubex/qpack v0.6.0/go.mod h1:lKGSi7Xk94IMvHGOmxS9eIei3bvIqpOAImEBsaOwTkA= github.com/metacubex/qpack v0.6.0/go.mod h1:lKGSi7Xk94IMvHGOmxS9eIei3bvIqpOAImEBsaOwTkA=
github.com/metacubex/quic-go v0.59.1-0.20260128071132-0f3233b973af h1:do5o1rzn64NEN5oGswo7VruDkbz2055fhVT3rXehA8E= github.com/metacubex/quic-go v0.59.1-0.20260213014310-4df8f0de5b56 h1:7yfF31COW2hiCovb5+3uSxRl3UKWOXjpS0j4N5U0qZ8=
github.com/metacubex/quic-go v0.59.1-0.20260128071132-0f3233b973af/go.mod h1:oNzMrmylS897M3zSMuapIdwSwfq6F2qW01Z3NhVRJhk= github.com/metacubex/quic-go v0.59.1-0.20260213014310-4df8f0de5b56/go.mod h1:oNzMrmylS897M3zSMuapIdwSwfq6F2qW01Z3NhVRJhk=
github.com/metacubex/randv2 v0.2.0 h1:uP38uBvV2SxYfLj53kuvAjbND4RUDfFJjwr4UigMiLs= github.com/metacubex/randv2 v0.2.0 h1:uP38uBvV2SxYfLj53kuvAjbND4RUDfFJjwr4UigMiLs=
github.com/metacubex/randv2 v0.2.0/go.mod h1:kFi2SzrQ5WuneuoLLCMkABtiBu6VRrMrWFqSPyj2cxY= github.com/metacubex/randv2 v0.2.0/go.mod h1:kFi2SzrQ5WuneuoLLCMkABtiBu6VRrMrWFqSPyj2cxY=
github.com/metacubex/restls-client-go v0.1.7 h1:eCwiXCTQb5WJu9IlgYvDBA1OgrINv58dEe7hcN5H15k= github.com/metacubex/restls-client-go v0.1.7 h1:eCwiXCTQb5WJu9IlgYvDBA1OgrINv58dEe7hcN5H15k=
@@ -131,8 +131,8 @@ github.com/metacubex/sing-shadowsocks2 v0.2.7 h1:hSuuc0YpsfiqYqt1o+fP4m34BQz4e6w
github.com/metacubex/sing-shadowsocks2 v0.2.7/go.mod h1:vOEbfKC60txi0ca+yUlqEwOGc3Obl6cnSgx9Gf45KjE= github.com/metacubex/sing-shadowsocks2 v0.2.7/go.mod h1:vOEbfKC60txi0ca+yUlqEwOGc3Obl6cnSgx9Gf45KjE=
github.com/metacubex/sing-shadowtls v0.0.0-20250503063515-5d9f966d17a2 h1:gXU+MYPm7Wme3/OAY2FFzVq9d9GxPHOqu5AQfg/ddhI= github.com/metacubex/sing-shadowtls v0.0.0-20250503063515-5d9f966d17a2 h1:gXU+MYPm7Wme3/OAY2FFzVq9d9GxPHOqu5AQfg/ddhI=
github.com/metacubex/sing-shadowtls v0.0.0-20250503063515-5d9f966d17a2/go.mod h1:mbfboaXauKJNIHJYxQRa+NJs4JU9NZfkA+I33dS2+9E= github.com/metacubex/sing-shadowtls v0.0.0-20250503063515-5d9f966d17a2/go.mod h1:mbfboaXauKJNIHJYxQRa+NJs4JU9NZfkA+I33dS2+9E=
github.com/metacubex/sing-tun v0.4.15 h1:0uOO8kCpodgs4Op8L7sn+C4J6a/lQagmeRTrzHxn+mo= github.com/metacubex/sing-tun v0.4.16-0.20260213034958-b09e3f5bbae6 h1:3yeZyDHGBmI/1XLsWBhr1sLhifWHkJa5J5Kf8djIbqs=
github.com/metacubex/sing-tun v0.4.15/go.mod h1:L/TjQY5JEGy8nvsuYmy/XgMFMCPiF0+AWSFCYfS6r9w= github.com/metacubex/sing-tun v0.4.16-0.20260213034958-b09e3f5bbae6/go.mod h1:L/TjQY5JEGy8nvsuYmy/XgMFMCPiF0+AWSFCYfS6r9w=
github.com/metacubex/sing-vmess v0.2.5 h1:m9Zt5I27lB9fmLMZfism9sH2LcnAfShZfwSkf6/KJoE= github.com/metacubex/sing-vmess v0.2.5 h1:m9Zt5I27lB9fmLMZfism9sH2LcnAfShZfwSkf6/KJoE=
github.com/metacubex/sing-vmess v0.2.5/go.mod h1:AwtlzUgf8COe9tRYAKqWZ+leDH7p5U98a0ZUpYehl8Q= github.com/metacubex/sing-vmess v0.2.5/go.mod h1:AwtlzUgf8COe9tRYAKqWZ+leDH7p5U98a0ZUpYehl8Q=
github.com/metacubex/sing-wireguard v0.0.0-20250503063753-2dc62acc626f h1:Sr/DYKYofKHKc4GF3qkRGNuj6XA6c0eqPgEDN+VAsYU= github.com/metacubex/sing-wireguard v0.0.0-20250503063753-2dc62acc626f h1:Sr/DYKYofKHKc4GF3qkRGNuj6XA6c0eqPgEDN+VAsYU=

View File

@@ -23,6 +23,7 @@ type SudokuServer struct {
DisableHTTPMask bool `json:"disable-http-mask,omitempty"` DisableHTTPMask bool `json:"disable-http-mask,omitempty"`
HTTPMaskMode string `json:"http-mask-mode,omitempty"` HTTPMaskMode string `json:"http-mask-mode,omitempty"`
PathRoot string `json:"path-root,omitempty"` PathRoot string `json:"path-root,omitempty"`
Fallback string `json:"fallback,omitempty"`
// mihomo private extension (not the part of standard Sudoku protocol) // mihomo private extension (not the part of standard Sudoku protocol)
MuxOption sing.MuxOption `json:"mux-option,omitempty"` MuxOption sing.MuxOption `json:"mux-option,omitempty"`

View File

@@ -0,0 +1,24 @@
package config
import (
"encoding/json"
)
type TrustTunnelServer struct {
Enable bool `yaml:"enable" json:"enable"`
Listen string `yaml:"listen" json:"listen"`
Users map[string]string `yaml:"users" json:"users,omitempty"`
Certificate string `yaml:"certificate" json:"certificate"`
PrivateKey string `yaml:"private-key" json:"private-key"`
ClientAuthType string `yaml:"client-auth-type" json:"client-auth-type,omitempty"`
ClientAuthCert string `yaml:"client-auth-cert" json:"client-auth-cert,omitempty"`
EchKey string `yaml:"ech-key" json:"ech-key"`
Network []string `yaml:"network" json:"network,omitempty"`
CongestionController string `yaml:"congestion-controller" json:"congestion-controller,omitempty"`
CWND int `yaml:"cwnd" json:"cwnd,omitempty"`
}
func (t TrustTunnelServer) String() string {
b, _ := json.Marshal(t)
return string(b)
}

View File

@@ -14,6 +14,7 @@ import (
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
mieruserver "github.com/enfein/mieru/v3/apis/server" mieruserver "github.com/enfein/mieru/v3/apis/server"
mierutp "github.com/enfein/mieru/v3/apis/trafficpattern"
mierupb "github.com/enfein/mieru/v3/pkg/appctl/appctlpb" mierupb "github.com/enfein/mieru/v3/pkg/appctl/appctlpb"
) )
@@ -26,8 +27,9 @@ type Mieru struct {
type MieruOption struct { type MieruOption struct {
BaseOption BaseOption
Transport string `inbound:"transport"` Transport string `inbound:"transport"`
Users map[string]string `inbound:"users"` Users map[string]string `inbound:"users"`
TrafficPattern string `inbound:"traffic-pattern,omitempty"`
} }
type mieruListenerFactory struct{} type mieruListenerFactory struct{}
@@ -154,10 +156,13 @@ func buildMieruServerConfig(option *MieruOption, ports utils.IntRanges[uint16])
Password: proto.String(password), Password: proto.String(password),
}) })
} }
var trafficPattern *mierupb.TrafficPattern
trafficPattern, _ = mierutp.Decode(option.TrafficPattern)
return &mieruserver.ServerConfig{ return &mieruserver.ServerConfig{
Config: &mierupb.ServerConfig{ Config: &mierupb.ServerConfig{
PortBindings: portBindings, PortBindings: portBindings,
Users: users, Users: users,
TrafficPattern: trafficPattern,
}, },
StreamListenerFactory: mieruListenerFactory{}, StreamListenerFactory: mieruListenerFactory{},
PacketListenerFactory: mieruListenerFactory{}, PacketListenerFactory: mieruListenerFactory{},
@@ -179,5 +184,14 @@ func validateMieruOption(option *MieruOption) error {
return fmt.Errorf("password is empty") return fmt.Errorf("password is empty")
} }
} }
if option.TrafficPattern != "" {
trafficPattern, err := mierutp.Decode(option.TrafficPattern)
if err != nil {
return fmt.Errorf("failed to decode traffic pattern %q: %w", option.TrafficPattern, err)
}
if err := mierutp.Validate(trafficPattern); err != nil {
return fmt.Errorf("invalid traffic pattern %q: %w", option.TrafficPattern, err)
}
}
return nil return nil
} }

View File

@@ -61,6 +61,20 @@ func TestNewMieru(t *testing.T) {
}, },
wantErr: false, wantErr: false,
}, },
{
name: "valid traffic pattern",
args: args{
option: &inbound.MieruOption{
BaseOption: inbound.BaseOption{
Port: "8080",
},
Transport: "TCP",
Users: map[string]string{"user": "pass"},
TrafficPattern: "GgQIARAK",
},
},
wantErr: false,
},
{ {
name: "invalid - no port", name: "invalid - no port",
args: args{ args: args{
@@ -135,6 +149,20 @@ func TestNewMieru(t *testing.T) {
}, },
wantErr: true, wantErr: true,
}, },
{
name: "invalid traffic pattern",
args: args{
option: &inbound.MieruOption{
BaseOption: inbound.BaseOption{
Port: "8080",
},
Transport: "TCP",
Users: map[string]string{"user": "pass"},
TrafficPattern: "1212ababXYYX",
},
},
wantErr: true,
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {

View File

@@ -13,23 +13,31 @@ import (
type SudokuOption struct { type SudokuOption struct {
BaseOption BaseOption
Key string `inbound:"key"` Key string `inbound:"key"`
AEADMethod string `inbound:"aead-method,omitempty"` AEADMethod string `inbound:"aead-method,omitempty"`
PaddingMin *int `inbound:"padding-min,omitempty"` PaddingMin *int `inbound:"padding-min,omitempty"`
PaddingMax *int `inbound:"padding-max,omitempty"` PaddingMax *int `inbound:"padding-max,omitempty"`
TableType string `inbound:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy" TableType string `inbound:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy"
HandshakeTimeoutSecond *int `inbound:"handshake-timeout,omitempty"` HandshakeTimeoutSecond *int `inbound:"handshake-timeout,omitempty"`
EnablePureDownlink *bool `inbound:"enable-pure-downlink,omitempty"` EnablePureDownlink *bool `inbound:"enable-pure-downlink,omitempty"`
CustomTable string `inbound:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv CustomTable string `inbound:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv
CustomTables []string `inbound:"custom-tables,omitempty"` CustomTables []string `inbound:"custom-tables,omitempty"`
DisableHTTPMask bool `inbound:"disable-http-mask,omitempty"` DisableHTTPMask bool `inbound:"disable-http-mask,omitempty"`
HTTPMaskMode string `inbound:"http-mask-mode,omitempty"` // "legacy" (default), "stream", "poll", "auto" HTTPMaskMode string `inbound:"http-mask-mode,omitempty"` // "legacy" (default), "stream", "poll", "auto"
PathRoot string `inbound:"path-root,omitempty"` // optional first-level path prefix for HTTP tunnel endpoints PathRoot string `inbound:"path-root,omitempty"` // optional first-level path prefix for HTTP tunnel endpoints
Fallback string `inbound:"fallback,omitempty"`
HTTPMaskOptions *SudokuHTTPMaskOptions `inbound:"httpmask,omitempty"`
// mihomo private extension (not the part of standard Sudoku protocol) // mihomo private extension (not the part of standard Sudoku protocol)
MuxOption MuxOption `inbound:"mux-option,omitempty"` MuxOption MuxOption `inbound:"mux-option,omitempty"`
} }
type SudokuHTTPMaskOptions struct {
Disable bool `inbound:"disable,omitempty"`
Mode string `inbound:"mode,omitempty"`
PathRoot string `inbound:"path_root,omitempty"`
}
func (o SudokuOption) Equal(config C.InboundConfig) bool { func (o SudokuOption) Equal(config C.InboundConfig) bool {
return optionToString(o) == optionToString(config) return optionToString(o) == optionToString(config)
} }
@@ -65,6 +73,16 @@ func NewSudoku(options *SudokuOption) (*Sudoku, error) {
DisableHTTPMask: options.DisableHTTPMask, DisableHTTPMask: options.DisableHTTPMask,
HTTPMaskMode: options.HTTPMaskMode, HTTPMaskMode: options.HTTPMaskMode,
PathRoot: strings.TrimSpace(options.PathRoot), PathRoot: strings.TrimSpace(options.PathRoot),
Fallback: strings.TrimSpace(options.Fallback),
}
if hm := options.HTTPMaskOptions; hm != nil {
serverConf.DisableHTTPMask = hm.Disable
if hm.Mode != "" {
serverConf.HTTPMaskMode = hm.Mode
}
if pr := strings.TrimSpace(hm.PathRoot); pr != "" {
serverConf.PathRoot = pr
}
} }
serverConf.MuxOption = options.MuxOption.Build() serverConf.MuxOption = options.MuxOption.Build()

View File

@@ -168,16 +168,17 @@ func TestInboundSudoku_CustomTable(t *testing.T) {
func TestInboundSudoku_HTTPMaskMode(t *testing.T) { func TestInboundSudoku_HTTPMaskMode(t *testing.T) {
key := "test_key_http_mask_mode" key := "test_key_http_mask_mode"
for _, mode := range []string{"legacy", "stream", "poll", "auto"} { for _, mode := range []string{"ws", "stream", "poll", "auto"} {
mode := mode mode := mode
t.Run(mode, func(t *testing.T) { t.Run(mode, func(t *testing.T) {
inboundOptions := inbound.SudokuOption{ inboundOptions := inbound.SudokuOption{
Key: key, Key: key,
HTTPMaskMode: mode, HTTPMaskMode: mode,
} }
httpMask := true
outboundOptions := outbound.SudokuOption{ outboundOptions := outbound.SudokuOption{
Key: key, Key: key,
HTTPMask: true, HTTPMask: &httpMask,
HTTPMaskMode: mode, HTTPMaskMode: mode,
} }
testInboundSudoku(t, inboundOptions, outboundOptions) testInboundSudoku(t, inboundOptions, outboundOptions)

View File

@@ -0,0 +1,96 @@
package inbound
import (
"strings"
C "github.com/metacubex/mihomo/constant"
LC "github.com/metacubex/mihomo/listener/config"
"github.com/metacubex/mihomo/listener/trusttunnel"
"github.com/metacubex/mihomo/log"
)
type TrustTunnelOption struct {
BaseOption
Users AuthUsers `inbound:"users,omitempty"`
Certificate string `inbound:"certificate"`
PrivateKey string `inbound:"private-key"`
ClientAuthType string `inbound:"client-auth-type,omitempty"`
ClientAuthCert string `inbound:"client-auth-cert,omitempty"`
EchKey string `inbound:"ech-key,omitempty"`
Network []string `inbound:"network,omitempty"`
CongestionController string `inbound:"congestion-controller,omitempty"`
CWND int `inbound:"cwnd,omitempty"`
}
func (o TrustTunnelOption) Equal(config C.InboundConfig) bool {
return optionToString(o) == optionToString(config)
}
type TrustTunnel struct {
*Base
config *TrustTunnelOption
l C.MultiAddrListener
vs LC.TrustTunnelServer
}
func NewTrustTunnel(options *TrustTunnelOption) (*TrustTunnel, error) {
base, err := NewBase(&options.BaseOption)
if err != nil {
return nil, err
}
users := make(map[string]string)
for _, user := range options.Users {
users[user.Username] = user.Password
}
return &TrustTunnel{
Base: base,
config: options,
vs: LC.TrustTunnelServer{
Enable: true,
Listen: base.RawAddress(),
Users: users,
Certificate: options.Certificate,
PrivateKey: options.PrivateKey,
ClientAuthType: options.ClientAuthType,
ClientAuthCert: options.ClientAuthCert,
EchKey: options.EchKey,
Network: options.Network,
CongestionController: options.CongestionController,
CWND: options.CWND,
},
}, nil
}
// Config implements constant.InboundListener
func (v *TrustTunnel) Config() C.InboundConfig {
return v.config
}
// Address implements constant.InboundListener
func (v *TrustTunnel) Address() string {
var addrList []string
if v.l != nil {
for _, addr := range v.l.AddrList() {
addrList = append(addrList, addr.String())
}
}
return strings.Join(addrList, ",")
}
// Listen implements constant.InboundListener
func (v *TrustTunnel) Listen(tunnel C.Tunnel) error {
var err error
v.l, err = trusttunnel.New(v.vs, tunnel, v.Additions()...)
if err != nil {
return err
}
log.Infoln("TrustTunnel[%s] proxy listening at: %s", v.Name(), v.Address())
return nil
}
// Close implements constant.InboundListener
func (v *TrustTunnel) Close() error {
return v.l.Close()
}
var _ C.InboundListener = (*TrustTunnel)(nil)

View File

@@ -0,0 +1,109 @@
package inbound_test
import (
"net/netip"
"testing"
"github.com/metacubex/mihomo/adapter/outbound"
"github.com/metacubex/mihomo/listener/inbound"
"github.com/stretchr/testify/assert"
)
func testInboundTrustTunnel(t *testing.T, inboundOptions inbound.TrustTunnelOption, outboundOptions outbound.TrustTunnelOption) {
t.Parallel()
inboundOptions.BaseOption = inbound.BaseOption{
NameStr: "trusttunnel_inbound",
Listen: "127.0.0.1",
Port: "0",
}
inboundOptions.Users = []inbound.AuthUser{{Username: "test", Password: userUUID}}
in, err := inbound.NewTrustTunnel(&inboundOptions)
if !assert.NoError(t, err) {
return
}
tunnel := NewHttpTestTunnel()
defer tunnel.Close()
err = in.Listen(tunnel)
if !assert.NoError(t, err) {
return
}
defer in.Close()
addrPort, err := netip.ParseAddrPort(in.Address())
if !assert.NoError(t, err) {
return
}
outboundOptions.Name = "trusttunnel_outbound"
outboundOptions.Server = addrPort.Addr().String()
outboundOptions.Port = int(addrPort.Port())
outboundOptions.UserName = "test"
outboundOptions.Password = userUUID
out, err := outbound.NewTrustTunnel(outboundOptions)
if !assert.NoError(t, err) {
return
}
defer out.Close()
tunnel.DoTest(t, out)
}
func testInboundTrustTunnelTLS(t *testing.T, quic bool) {
inboundOptions := inbound.TrustTunnelOption{
Certificate: tlsCertificate,
PrivateKey: tlsPrivateKey,
}
outboundOptions := outbound.TrustTunnelOption{
Fingerprint: tlsFingerprint,
HealthCheck: true,
}
if quic {
inboundOptions.Network = []string{"udp"}
inboundOptions.CongestionController = "bbr"
outboundOptions.Quic = true
}
testInboundTrustTunnel(t, inboundOptions, outboundOptions)
t.Run("ECH", func(t *testing.T) {
inboundOptions := inboundOptions
outboundOptions := outboundOptions
inboundOptions.EchKey = echKeyPem
outboundOptions.ECHOpts = outbound.ECHOptions{
Enable: true,
Config: echConfigBase64,
}
testInboundTrustTunnel(t, inboundOptions, outboundOptions)
})
t.Run("mTLS", func(t *testing.T) {
inboundOptions := inboundOptions
outboundOptions := outboundOptions
inboundOptions.ClientAuthCert = tlsAuthCertificate
outboundOptions.Certificate = tlsAuthCertificate
outboundOptions.PrivateKey = tlsAuthPrivateKey
testInboundTrustTunnel(t, inboundOptions, outboundOptions)
})
t.Run("mTLS+ECH", func(t *testing.T) {
inboundOptions := inboundOptions
outboundOptions := outboundOptions
inboundOptions.ClientAuthCert = tlsAuthCertificate
outboundOptions.Certificate = tlsAuthCertificate
outboundOptions.PrivateKey = tlsAuthPrivateKey
inboundOptions.EchKey = echKeyPem
outboundOptions.ECHOpts = outbound.ECHOptions{
Enable: true,
Config: echConfigBase64,
}
testInboundTrustTunnel(t, inboundOptions, outboundOptions)
})
}
func TestInboundTrustTunnel_H2(t *testing.T) {
testInboundTrustTunnelTLS(t, true)
}
func TestInboundTrustTunnel_QUIC(t *testing.T) {
testInboundTrustTunnelTLS(t, true)
}

View File

@@ -26,7 +26,7 @@ type VmessOption struct {
type VmessUser struct { type VmessUser struct {
Username string `inbound:"username,omitempty"` Username string `inbound:"username,omitempty"`
UUID string `inbound:"uuid"` UUID string `inbound:"uuid"`
AlterID int `inbound:"alterId"` AlterID int `inbound:"alterId,omitempty"`
} }
func (o VmessOption) Equal(config C.InboundConfig) bool { func (o VmessOption) Equal(config C.InboundConfig) bool {

View File

@@ -141,6 +141,13 @@ func ParseListener(mapping map[string]any) (C.InboundListener, error) {
return nil, err return nil, err
} }
listener, err = IN.NewSudoku(sudokuOption) listener, err = IN.NewSudoku(sudokuOption)
case "trusttunnel":
trusttunnelOption := &IN.TrustTunnelOption{}
err = decoder.Decode(mapping, trusttunnelOption)
if err != nil {
return nil, err
}
listener, err = IN.NewTrustTunnel(trusttunnelOption)
default: default:
return nil, fmt.Errorf("unsupport proxy type: %s", proxyType) return nil, fmt.Errorf("unsupport proxy type: %s", proxyType)
} }

View File

@@ -1,16 +1,19 @@
package sudoku package sudoku
import ( import (
"bytes"
"errors" "errors"
"io" "io"
"net" "net"
"strings" "strings"
"time"
"github.com/metacubex/mihomo/adapter/inbound" "github.com/metacubex/mihomo/adapter/inbound"
N "github.com/metacubex/mihomo/common/net" N "github.com/metacubex/mihomo/common/net"
"github.com/metacubex/mihomo/common/utils" "github.com/metacubex/mihomo/common/utils"
C "github.com/metacubex/mihomo/constant" C "github.com/metacubex/mihomo/constant"
LC "github.com/metacubex/mihomo/listener/config" LC "github.com/metacubex/mihomo/listener/config"
"github.com/metacubex/mihomo/listener/inner"
"github.com/metacubex/mihomo/listener/sing" "github.com/metacubex/mihomo/listener/sing"
"github.com/metacubex/mihomo/log" "github.com/metacubex/mihomo/log"
"github.com/metacubex/mihomo/transport/socks5" "github.com/metacubex/mihomo/transport/socks5"
@@ -23,6 +26,7 @@ type Listener struct {
closed bool closed bool
protoConf sudoku.ProtocolConfig protoConf sudoku.ProtocolConfig
tunnelSrv *sudoku.HTTPMaskTunnelServer tunnelSrv *sudoku.HTTPMaskTunnelServer
fallback string
handler *sing.ListenerHandler handler *sing.ListenerHandler
} }
@@ -49,12 +53,19 @@ func (l *Listener) Close() error {
} }
func (l *Listener) handleConn(conn net.Conn, tunnel C.Tunnel, additions ...inbound.Addition) { func (l *Listener) handleConn(conn net.Conn, tunnel C.Tunnel, additions ...inbound.Addition) {
log.Debugln("[Sudoku] accepted %s", conn.RemoteAddr())
handshakeConn := conn handshakeConn := conn
handshakeCfg := &l.protoConf handshakeCfg := &l.protoConf
closeConns := func() {
_ = handshakeConn.Close()
if handshakeConn != conn {
_ = conn.Close()
}
}
if l.tunnelSrv != nil { if l.tunnelSrv != nil {
c, cfg, done, err := l.tunnelSrv.WrapConn(conn) c, cfg, done, err := l.tunnelSrv.WrapConn(conn)
if err != nil { if err != nil {
_ = conn.Close() closeConns()
return return
} }
if done { if done {
@@ -68,9 +79,43 @@ func (l *Listener) handleConn(conn net.Conn, tunnel C.Tunnel, additions ...inbou
} }
} }
session, err := sudoku.ServerHandshake(handshakeConn, handshakeCfg) if l.fallback != "" {
if r, ok := handshakeConn.(interface{ IsHTTPMaskRejected() bool }); ok && r.IsHTTPMaskRejected() {
fb, err := inner.HandleTcp(tunnel, l.fallback, "")
if err != nil {
closeConns()
return
}
N.Relay(handshakeConn, fb)
return
}
}
cConn, meta, err := sudoku.ServerHandshake(handshakeConn, handshakeCfg)
if err != nil { if err != nil {
_ = handshakeConn.Close() fallbackAddr := l.fallback
var susp *sudoku.SuspiciousError
isSuspicious := errors.As(err, &susp) && susp != nil && susp.Conn != nil
if isSuspicious {
log.Warnln("[Sudoku] suspicious handshake from %s: %v", conn.RemoteAddr(), err)
if fallbackAddr != "" {
fb, err := inner.HandleTcp(tunnel, fallbackAddr, "")
if err == nil {
relayToFallback(susp.Conn, conn, fb)
return
}
}
} else {
log.Debugln("[Sudoku] handshake failed from %s: %v", conn.RemoteAddr(), err)
}
closeConns()
return
}
session, err := sudoku.ReadServerSession(cConn, meta)
if err != nil {
log.Warnln("[Sudoku] read session failed from %s: %v", conn.RemoteAddr(), err)
_ = cConn.Close()
if handshakeConn != conn { if handshakeConn != conn {
_ = conn.Close() _ = conn.Close()
} }
@@ -103,6 +148,7 @@ func (l *Listener) handleConn(conn net.Conn, tunnel C.Tunnel, additions ...inbou
default: default:
targetAddr := socks5.ParseAddr(session.Target) targetAddr := socks5.ParseAddr(session.Target)
if targetAddr == nil { if targetAddr == nil {
log.Warnln("[Sudoku] invalid target from %s: %q", conn.RemoteAddr(), session.Target)
_ = session.Conn.Close() _ = session.Conn.Close()
return return
} }
@@ -164,6 +210,24 @@ func (p *uotPacket) LocalAddr() net.Addr {
return p.rAddr return p.rAddr
} }
func relayToFallback(wrapper net.Conn, rawConn net.Conn, fallback net.Conn) {
if wrapper != nil {
if recorder, ok := wrapper.(interface{ GetBufferedAndRecorded() []byte }); ok {
badData := recorder.GetBufferedAndRecorded()
if len(badData) > 0 {
_ = fallback.SetWriteDeadline(time.Now().Add(3 * time.Second))
if _, err := io.Copy(fallback, bytes.NewReader(badData)); err != nil {
_ = fallback.Close()
_ = rawConn.Close()
return
}
_ = fallback.SetWriteDeadline(time.Time{})
}
}
}
N.Relay(rawConn, fallback)
}
func New(config LC.SudokuServer, tunnel C.Tunnel, additions ...inbound.Addition) (*Listener, error) { func New(config LC.SudokuServer, tunnel C.Tunnel, additions ...inbound.Addition) (*Listener, error) {
if len(additions) == 0 { if len(additions) == 0 {
additions = []inbound.Addition{ additions = []inbound.Addition{
@@ -188,42 +252,24 @@ func New(config LC.SudokuServer, tunnel C.Tunnel, additions ...inbound.Addition)
return nil, err return nil, err
} }
tableType := strings.ToLower(config.TableType) tableType, err := sudoku.NormalizeTableType(config.TableType)
if tableType == "" {
tableType = "prefer_ascii"
}
defaultConf := sudoku.DefaultConfig()
paddingMin := defaultConf.PaddingMin
paddingMax := defaultConf.PaddingMax
if config.PaddingMin != nil {
paddingMin = *config.PaddingMin
}
if config.PaddingMax != nil {
paddingMax = *config.PaddingMax
}
if config.PaddingMin == nil && config.PaddingMax != nil && paddingMax < paddingMin {
paddingMin = paddingMax
}
if config.PaddingMax == nil && config.PaddingMin != nil && paddingMax < paddingMin {
paddingMax = paddingMin
}
enablePureDownlink := defaultConf.EnablePureDownlink
if config.EnablePureDownlink != nil {
enablePureDownlink = *config.EnablePureDownlink
}
tables, err := sudoku.NewTablesWithCustomPatterns(config.Key, tableType, config.CustomTable, config.CustomTables)
if err != nil { if err != nil {
_ = l.Close() _ = l.Close()
return nil, err return nil, err
} }
handshakeTimeout := defaultConf.HandshakeTimeoutSeconds defaultConf := sudoku.DefaultConfig()
if config.HandshakeTimeoutSecond != nil { paddingMin, paddingMax := sudoku.ResolvePadding(config.PaddingMin, config.PaddingMax, defaultConf.PaddingMin, defaultConf.PaddingMax)
handshakeTimeout = *config.HandshakeTimeoutSecond enablePureDownlink := sudoku.DerefBool(config.EnablePureDownlink, defaultConf.EnablePureDownlink)
tables, err := sudoku.NewServerTablesWithCustomPatterns(sudoku.ServerAEADSeed(config.Key), tableType, config.CustomTable, config.CustomTables)
if err != nil {
_ = l.Close()
return nil, err
} }
handshakeTimeout := sudoku.DerefInt(config.HandshakeTimeoutSecond, defaultConf.HandshakeTimeoutSeconds)
protoConf := sudoku.ProtocolConfig{ protoConf := sudoku.ProtocolConfig{
Key: config.Key, Key: config.Key,
AEADMethod: defaultConf.AEADMethod, AEADMethod: defaultConf.AEADMethod,
@@ -249,8 +295,13 @@ func New(config LC.SudokuServer, tunnel C.Tunnel, additions ...inbound.Addition)
addr: config.Listen, addr: config.Listen,
protoConf: protoConf, protoConf: protoConf,
handler: h, handler: h,
fallback: strings.TrimSpace(config.Fallback),
}
if sl.fallback != "" {
sl.tunnelSrv = sudoku.NewHTTPMaskTunnelServerWithFallback(&sl.protoConf)
} else {
sl.tunnelSrv = sudoku.NewHTTPMaskTunnelServer(&sl.protoConf)
} }
sl.tunnelSrv = sudoku.NewHTTPMaskTunnelServer(&sl.protoConf)
go func() { go func() {
for { for {

View File

@@ -0,0 +1,188 @@
package trusttunnel
import (
"context"
"errors"
"net"
"strings"
"github.com/metacubex/mihomo/adapter/inbound"
"github.com/metacubex/mihomo/common/sockopt"
"github.com/metacubex/mihomo/component/ca"
"github.com/metacubex/mihomo/component/ech"
C "github.com/metacubex/mihomo/constant"
LC "github.com/metacubex/mihomo/listener/config"
"github.com/metacubex/mihomo/listener/sing"
"github.com/metacubex/mihomo/log"
"github.com/metacubex/mihomo/ntp"
"github.com/metacubex/mihomo/transport/trusttunnel"
"github.com/metacubex/tls"
)
type Listener struct {
closed bool
config LC.TrustTunnelServer
listeners []net.Listener
udpListeners []net.PacketConn
tlsConfig *tls.Config
services []*trusttunnel.Service
}
func New(config LC.TrustTunnelServer, tunnel C.Tunnel, additions ...inbound.Addition) (sl *Listener, err error) {
if len(additions) == 0 {
additions = []inbound.Addition{
inbound.WithInName("DEFAULT-TRUSTTUNNEL"),
inbound.WithSpecialRules(""),
}
}
tlsConfig := &tls.Config{Time: ntp.Now}
if config.Certificate != "" && config.PrivateKey != "" {
certLoader, err := ca.NewTLSKeyPairLoader(config.Certificate, config.PrivateKey)
if err != nil {
return nil, err
}
tlsConfig.GetCertificate = func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
return certLoader()
}
if config.EchKey != "" {
err = ech.LoadECHKey(config.EchKey, tlsConfig)
if err != nil {
return nil, err
}
}
}
tlsConfig.ClientAuth = ca.ClientAuthTypeFromString(config.ClientAuthType)
if len(config.ClientAuthCert) > 0 {
if tlsConfig.ClientAuth == tls.NoClientCert {
tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
}
}
if tlsConfig.ClientAuth == tls.VerifyClientCertIfGiven || tlsConfig.ClientAuth == tls.RequireAndVerifyClientCert {
pool, err := ca.LoadCertificates(config.ClientAuthCert)
if err != nil {
return nil, err
}
tlsConfig.ClientCAs = pool
}
sl = &Listener{
config: config,
tlsConfig: tlsConfig,
}
h, err := sing.NewListenerHandler(sing.ListenerConfig{
Tunnel: tunnel,
Type: C.TRUSTTUNNEL,
Additions: additions,
})
if err != nil {
return nil, err
}
if tlsConfig.GetCertificate == nil {
return nil, errors.New("disallow using TrustTunnel without certificates config")
}
if len(config.Network) == 0 {
config.Network = []string{"tcp"}
}
listenTCP, listenUDP := false, false
for _, network := range config.Network {
network = strings.ToLower(network)
switch {
case strings.HasPrefix(network, "tcp"):
listenTCP = true
case strings.HasPrefix(network, "udp"):
listenUDP = true
}
}
for _, addr := range strings.Split(config.Listen, ",") {
addr := addr
var (
tcpListener net.Listener
udpConn net.PacketConn
)
if listenTCP {
tcpListener, err = inbound.Listen("tcp", addr)
if err != nil {
_ = sl.Close()
return nil, err
}
sl.listeners = append(sl.listeners, tcpListener)
}
if listenUDP {
udpConn, err = inbound.ListenPacket("udp", addr)
if err != nil {
_ = sl.Close()
return nil, err
}
if err := sockopt.UDPReuseaddr(udpConn); err != nil {
log.Warnln("Failed to Reuse UDP Address: %s", err)
}
sl.udpListeners = append(sl.udpListeners, udpConn)
}
service := trusttunnel.NewService(trusttunnel.ServiceOptions{
Ctx: context.Background(),
Logger: log.SingLogger,
Handler: h,
ICMPHandler: nil,
QUICCongestionControl: config.CongestionController,
QUICCwnd: config.CWND,
})
service.UpdateUsers(config.Users)
err = service.Start(tcpListener, udpConn, tlsConfig)
if err != nil {
_ = sl.Close()
return nil, err
}
sl.services = append(sl.services, service)
}
return sl, nil
}
func (l *Listener) Close() error {
l.closed = true
var retErr error
for _, lis := range l.services {
err := lis.Close()
if err != nil {
retErr = err
}
}
for _, lis := range l.listeners {
err := lis.Close()
if err != nil {
retErr = err
}
}
for _, lis := range l.udpListeners {
err := lis.Close()
if err != nil {
retErr = err
}
}
return retErr
}
func (l *Listener) Config() string {
return l.config.String()
}
func (l *Listener) AddrList() (addrList []net.Addr) {
for _, lis := range l.listeners {
addrList = append(addrList, lis.Addr())
}
for _, lis := range l.udpListeners {
addrList = append(addrList, lis.LocalAddr())
}
return
}

View File

@@ -18,9 +18,9 @@ import (
"github.com/metacubex/mihomo/common/buf" "github.com/metacubex/mihomo/common/buf"
"github.com/metacubex/mihomo/common/pool" "github.com/metacubex/mihomo/common/pool"
"github.com/metacubex/mihomo/component/ech"
tlsC "github.com/metacubex/mihomo/component/tls" tlsC "github.com/metacubex/mihomo/component/tls"
C "github.com/metacubex/mihomo/constant" C "github.com/metacubex/mihomo/constant"
"github.com/metacubex/mihomo/transport/vmess"
"github.com/metacubex/http" "github.com/metacubex/http"
"github.com/metacubex/http/httptrace" "github.com/metacubex/http/httptrace"
@@ -40,10 +40,10 @@ var defaultHeader = http.Header{
type DialFn = func(ctx context.Context, network, addr string) (net.Conn, error) type DialFn = func(ctx context.Context, network, addr string) (net.Conn, error)
type Conn struct { type Conn struct {
initFn func() (io.ReadCloser, netAddr, error) initFn func() (io.ReadCloser, NetAddr, error)
writer io.Writer // writer must not nil writer io.Writer // writer must not nil
closer io.Closer closer io.Closer
netAddr NetAddr
initOnce sync.Once initOnce sync.Once
initErr error initErr error
@@ -59,10 +59,9 @@ type Conn struct {
} }
type Config struct { type Config struct {
ServiceName string ServiceName string
UserAgent string UserAgent string
Host string Host string
ClientFingerprint string
} }
func (g *Conn) initReader() { func (g *Conn) initReader() {
@@ -74,7 +73,7 @@ func (g *Conn) initReader() {
} }
return return
} }
g.netAddr = addr g.NetAddr = addr
g.closeMutex.Lock() g.closeMutex.Lock()
defer g.closeMutex.Unlock() defer g.closeMutex.Unlock()
@@ -247,7 +246,7 @@ func (g *Conn) SetDeadline(t time.Time) error {
return nil return nil
} }
func NewHTTP2Client(dialFn DialFn, tlsConfig *tls.Config, clientFingerprint string, echConfig *ech.Config, realityConfig *tlsC.RealityConfig) *TransportWrap { func NewHTTP2Client(dialFn DialFn, tlsConfig *vmess.TLSConfig) *TransportWrap {
dialFunc := func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) { dialFunc := func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
ctx, cancel := context.WithTimeout(ctx, C.DefaultTLSTimeout) ctx, cancel := context.WithTimeout(ctx, C.DefaultTLSTimeout)
defer cancel() defer cancel()
@@ -260,65 +259,33 @@ func NewHTTP2Client(dialFn DialFn, tlsConfig *tls.Config, clientFingerprint stri
return pconn, nil return pconn, nil
} }
if clientFingerprint, ok := tlsC.GetFingerprint(clientFingerprint); ok { conn, err := vmess.StreamTLSConn(ctx, pconn, tlsConfig)
if realityConfig == nil { if err != nil {
tlsConfig := tlsC.UConfig(cfg) _ = pconn.Close()
err := echConfig.ClientHandleUTLS(ctx, tlsConfig) return nil, err
if err != nil { }
pconn.Close()
return nil, err if tlsConfig.Reality == nil { // reality doesn't return the negotiated ALPN
} switch tlsConn := conn.(type) {
tlsConn := tlsC.UClient(pconn, tlsConfig, clientFingerprint) case interface{ ConnectionState() tls.ConnectionState }:
if err := tlsConn.HandshakeContext(ctx); err != nil {
pconn.Close()
return nil, err
}
state := tlsConn.ConnectionState() state := tlsConn.ConnectionState()
if p := state.NegotiatedProtocol; p != http.Http2NextProtoTLS { if p := state.NegotiatedProtocol; p != http.Http2NextProtoTLS {
tlsConn.Close() _ = conn.Close()
return nil, fmt.Errorf("http2: unexpected ALPN protocol %s, want %s", p, http.Http2NextProtoTLS) return nil, fmt.Errorf("http2: unexpected ALPN protocol %s, want %s", p, http.Http2NextProtoTLS)
} }
return tlsConn, nil case interface{ ConnectionState() tlsC.ConnectionState }:
} else { state := tlsConn.ConnectionState()
realityConn, err := tlsC.GetRealityConn(ctx, pconn, clientFingerprint, cfg.ServerName, realityConfig) if p := state.NegotiatedProtocol; p != http.Http2NextProtoTLS {
if err != nil { _ = conn.Close()
pconn.Close() return nil, fmt.Errorf("http2: unexpected ALPN protocol %s, want %s", p, http.Http2NextProtoTLS)
return nil, err
} }
//state := realityConn.(*utls.UConn).ConnectionState()
//if p := state.NegotiatedProtocol; p != http.Http2NextProtoTLS {
// realityConn.Close()
// return nil, fmt.Errorf("http2: unexpected ALPN protocol %s, want %s", p, http.Http2NextProtoTLS)
//}
return realityConn, nil
} }
} }
if realityConfig != nil {
return nil, errors.New("REALITY is based on uTLS, please set a client-fingerprint")
}
err = echConfig.ClientHandle(ctx, cfg)
if err != nil {
pconn.Close()
return nil, err
}
conn := tls.Client(pconn, cfg)
if err := conn.HandshakeContext(ctx); err != nil {
pconn.Close()
return nil, err
}
state := conn.ConnectionState()
if p := state.NegotiatedProtocol; p != http.Http2NextProtoTLS {
conn.Close()
return nil, fmt.Errorf("http2: unexpected ALPN protocol %s, want %s", p, http.Http2NextProtoTLS)
}
return conn, nil return conn, nil
} }
transport := &http.Http2Transport{ transport := &http.Http2Transport{
DialTLSContext: dialFunc, DialTLSContext: dialFunc,
TLSClientConfig: tlsConfig,
AllowHTTP: false, AllowHTTP: false,
DisableCompression: true, DisableCompression: true,
PingTimeout: 0, PingTimeout: 0,
@@ -351,7 +318,7 @@ func StreamGunWithTransport(transport *TransportWrap, cfg *Config) (net.Conn, er
header := defaultHeader.Clone() header := defaultHeader.Clone()
if cfg.UserAgent != "" { if cfg.UserAgent != "" {
header["user-agent"] = []string{cfg.UserAgent} header.Set("User-Agent", cfg.UserAgent)
} }
request := &http.Request{ request := &http.Request{
@@ -372,12 +339,12 @@ func StreamGunWithTransport(transport *TransportWrap, cfg *Config) (net.Conn, er
request = request.WithContext(transport.ctx) request = request.WithContext(transport.ctx)
conn := &Conn{ conn := &Conn{
initFn: func() (io.ReadCloser, netAddr, error) { initFn: func() (io.ReadCloser, NetAddr, error) {
nAddr := netAddr{} nAddr := NetAddr{}
trace := &httptrace.ClientTrace{ trace := &httptrace.ClientTrace{
GotConn: func(connInfo httptrace.GotConnInfo) { GotConn: func(connInfo httptrace.GotConnInfo) {
nAddr.localAddr = connInfo.Conn.LocalAddr() nAddr.SetLocalAddr(connInfo.Conn.LocalAddr())
nAddr.remoteAddr = connInfo.Conn.RemoteAddr() nAddr.SetRemoteAddr(connInfo.Conn.RemoteAddr())
}, },
} }
request = request.WithContext(httptrace.WithClientTrace(request.Context(), trace)) request = request.WithContext(httptrace.WithClientTrace(request.Context(), trace))
@@ -394,12 +361,12 @@ func StreamGunWithTransport(transport *TransportWrap, cfg *Config) (net.Conn, er
return conn, nil return conn, nil
} }
func StreamGunWithConn(conn net.Conn, tlsConfig *tls.Config, cfg *Config, echConfig *ech.Config, realityConfig *tlsC.RealityConfig) (net.Conn, error) { func StreamGunWithConn(conn net.Conn, tlsConfig *vmess.TLSConfig, cfg *Config) (net.Conn, error) {
dialFn := func(ctx context.Context, network, addr string) (net.Conn, error) { dialFn := func(ctx context.Context, network, addr string) (net.Conn, error) {
return conn, nil return conn, nil
} }
transport := NewHTTP2Client(dialFn, tlsConfig, cfg.ClientFingerprint, echConfig, realityConfig) transport := NewHTTP2Client(dialFn, tlsConfig)
c, err := StreamGunWithTransport(transport, cfg) c, err := StreamGunWithTransport(transport, cfg)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -9,7 +9,6 @@ import (
"github.com/metacubex/mihomo/common/buf" "github.com/metacubex/mihomo/common/buf"
N "github.com/metacubex/mihomo/common/net" N "github.com/metacubex/mihomo/common/net"
C "github.com/metacubex/mihomo/constant"
"github.com/metacubex/http" "github.com/metacubex/http"
"github.com/metacubex/http/h2c" "github.com/metacubex/http/h2c"
@@ -42,17 +41,9 @@ func NewServerHandler(options ServerOption) http.Handler {
writer.WriteHeader(http.StatusOK) writer.WriteHeader(http.StatusOK)
conn := &Conn{ conn := &Conn{
initFn: func() (io.ReadCloser, netAddr, error) { initFn: func() (io.ReadCloser, NetAddr, error) {
nAddr := netAddr{} nAddr := NetAddr{}
if request.RemoteAddr != "" { nAddr.SetAddrFromRequest(request)
metadata := C.Metadata{}
if err := metadata.SetRemoteAddress(request.RemoteAddr); err == nil {
nAddr.remoteAddr = net.TCPAddrFromAddrPort(metadata.AddrPort())
}
}
if addr, ok := request.Context().Value(http.LocalAddrContextKey).(net.Addr); ok {
nAddr.localAddr = addr
}
return request.Body, nAddr, nil return request.Body, nAddr, nil
}, },
writer: writer, writer: writer,

View File

@@ -5,6 +5,8 @@ import (
"net" "net"
"sync" "sync"
C "github.com/metacubex/mihomo/constant"
"github.com/metacubex/http" "github.com/metacubex/http"
) )
@@ -18,20 +20,40 @@ type TransportWrap struct {
func (tw *TransportWrap) Close() error { func (tw *TransportWrap) Close() error {
tw.closeOnce.Do(func() { tw.closeOnce.Do(func() {
tw.cancel() tw.cancel()
closeTransport(tw.Http2Transport) CloseTransport(tw.Http2Transport)
}) })
return nil return nil
} }
type netAddr struct { type NetAddr struct {
remoteAddr net.Addr remoteAddr net.Addr
localAddr net.Addr localAddr net.Addr
} }
func (addr netAddr) RemoteAddr() net.Addr { func (addr NetAddr) RemoteAddr() net.Addr {
return addr.remoteAddr return addr.remoteAddr
} }
func (addr netAddr) LocalAddr() net.Addr { func (addr NetAddr) LocalAddr() net.Addr {
return addr.localAddr return addr.localAddr
} }
func (addr *NetAddr) SetAddrFromRequest(request *http.Request) {
if request.RemoteAddr != "" {
metadata := C.Metadata{}
if err := metadata.SetRemoteAddress(request.RemoteAddr); err == nil {
addr.remoteAddr = net.TCPAddrFromAddrPort(metadata.AddrPort())
}
}
if netAddr, ok := request.Context().Value(http.LocalAddrContextKey).(net.Addr); ok {
addr.localAddr = netAddr
}
}
func (addr *NetAddr) SetRemoteAddr(remoteAddr net.Addr) {
addr.remoteAddr = remoteAddr
}
func (addr *NetAddr) SetLocalAddr(localAddr net.Addr) {
addr.localAddr = localAddr
}

View File

@@ -44,7 +44,7 @@ func closeClientConn(cc *http.Http2ClientConn) { // like forceCloseConn() in htt
_ = cc.Close() _ = cc.Close()
} }
func closeTransport(tr *http.Http2Transport) { func CloseTransport(tr *http.Http2Transport) {
connPool := transportConnPool(tr) connPool := transportConnPool(tr)
p := (*clientConnPool)((*efaceWords)(unsafe.Pointer(&connPool)).data) p := (*clientConnPool)((*efaceWords)(unsafe.Pointer(&connPool)).data)
p.mu.Lock() p.mu.Lock()

View File

@@ -6,6 +6,7 @@ import (
"io" "io"
"net" "net"
"strconv" "strconv"
"strings"
) )
func EncodeAddress(rawAddr string) ([]byte, error) { func EncodeAddress(rawAddr string) ([]byte, error) {
@@ -20,13 +21,21 @@ func EncodeAddress(rawAddr string) ([]byte, error) {
} }
var buf []byte 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 ip := net.ParseIP(host); ip != nil {
if ip4 := ip.To4(); ip4 != nil { if ip4 := ip.To4(); ip4 != nil {
buf = append(buf, 0x01) // IPv4 buf = append(buf, 0x01) // IPv4
buf = append(buf, ip4...) buf = append(buf, ip4...)
} else { } else {
buf = append(buf, 0x04) // IPv6 buf = append(buf, 0x04) // IPv6
buf = append(buf, ip...) ip16 := ip.To16()
if ip16 == nil {
return nil, fmt.Errorf("invalid ipv6: %q", host)
}
buf = append(buf, ip16...)
} }
} else { } else {
if len(host) > 255 { if len(host) > 255 {

View File

@@ -50,6 +50,7 @@ type ProtocolConfig struct {
// - "stream": real HTTP tunnel (split-stream), CDN-compatible // - "stream": real HTTP tunnel (split-stream), CDN-compatible
// - "poll": plain HTTP tunnel (authorize/push/pull), strong restricted-network pass-through // - "poll": plain HTTP tunnel (authorize/push/pull), strong restricted-network pass-through
// - "auto": try stream then fall back to poll // - "auto": try stream then fall back to poll
// - "ws": WebSocket tunnel (GET upgrade), CDN-friendly
HTTPMaskMode string HTTPMaskMode string
// HTTPMaskTLSEnabled enables HTTPS for HTTP tunnel modes (client-side). // HTTPMaskTLSEnabled enables HTTPS for HTTP tunnel modes (client-side).
@@ -109,9 +110,9 @@ func (c *ProtocolConfig) Validate() error {
} }
switch strings.ToLower(strings.TrimSpace(c.HTTPMaskMode)) { switch strings.ToLower(strings.TrimSpace(c.HTTPMaskMode)) {
case "", "legacy", "stream", "poll", "auto": case "", "legacy", "stream", "poll", "auto", "ws":
default: default:
return fmt.Errorf("invalid http-mask-mode: %s, must be one of: legacy, stream, poll, auto", c.HTTPMaskMode) 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 != "" { if v := strings.TrimSpace(c.HTTPMaskPathRoot); v != "" {
@@ -166,6 +167,44 @@ func DefaultConfig() *ProtocolConfig {
} }
} }
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) {
switch t := strings.ToLower(strings.TrimSpace(tableType)); t {
case "", "prefer_ascii":
return "prefer_ascii", nil
case "prefer_entropy":
return "prefer_entropy", nil
default:
return "", fmt.Errorf("table-type must be prefer_ascii or prefer_entropy")
}
}
func (c *ProtocolConfig) tableCandidates() []*sudoku.Table { func (c *ProtocolConfig) tableCandidates() []*sudoku.Table {
if c == nil { if c == nil {
return nil return nil

View File

@@ -6,7 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"filippo.io/edwards25519" "github.com/metacubex/edwards25519"
) )
// KeyPair holds the scalar private key and point public key // KeyPair holds the scalar private key and point public key
@@ -80,8 +80,8 @@ func RecoverPublicKey(keyHex string) (*edwards25519.Point, error) {
return nil, fmt.Errorf("invalid scalar: %w", err) return nil, fmt.Errorf("invalid scalar: %w", err)
} }
return new(edwards25519.Point).ScalarBaseMult(x), nil return new(edwards25519.Point).ScalarBaseMult(x), nil
}
} else if len(keyBytes) == 64 { if len(keyBytes) == 64 {
// Split Key r || k // Split Key r || k
rBytes := keyBytes[:32] rBytes := keyBytes[:32]
kBytes := keyBytes[32:] kBytes := keyBytes[32:]

View File

@@ -0,0 +1,374 @@
package crypto
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"sync"
"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
// Receive direction state.
recvEpoch uint32
recvSeq uint64
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)}
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)}
c.sendEpoch = 0
c.sendSeq = 0
c.sendBytes = 0
c.recvEpoch = 0
c.recvSeq = 0
c.readBuf.Reset()
c.sendAEAD = nil
c.recvAEAD = nil
c.sendAEADEpoch = 0
c.recvAEADEpoch = 0
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 (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) {
if KeyUpdateAfterBytes <= 0 || c.method == "none" {
return
}
c.sendBytes += int64(addedPlain)
threshold := KeyUpdateAfterBytes * int64(c.sendEpoch+1)
if c.sendBytes < threshold {
return
}
c.sendEpoch++
c.sendSeq = 0
}
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
c.maybeBumpSendEpochLocked(n)
}
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 epoch < c.recvEpoch {
return 0, fmt.Errorf("replayed epoch: got %d want >=%d", epoch, c.recvEpoch)
}
if epoch == c.recvEpoch && seq != c.recvSeq {
return 0, 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 0, fmt.Errorf("epoch jump too large: got=%d want<=%d", epoch-c.recvEpoch, maxJump)
}
c.recvEpoch = epoch
c.recvSeq = 0
if seq != 0 {
return 0, fmt.Errorf("out of order: epoch advanced to %d but seq=%d", epoch, seq)
}
}
if c.recvAEAD == nil || c.recvAEADEpoch != c.recvEpoch {
a, err := c.newAEADFor(c.keys.baseRecv, c.recvEpoch)
if err != nil {
return 0, err
}
c.recvAEAD = a
c.recvAEADEpoch = c.recvEpoch
}
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.recvSeq++
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
}

View File

@@ -43,12 +43,17 @@ func TestCustomTablesRotation_ProbedByServer(t *testing.T) {
go func() { go func() {
defer close(errCh) defer close(errCh)
defer serverConn.Close() defer serverConn.Close()
session, err := ServerHandshake(serverConn, serverCfg) c, meta, err := ServerHandshake(serverConn, serverCfg)
if err != nil { if err != nil {
errCh <- err errCh <- err
return return
} }
defer session.Conn.Close() session, err := ReadServerSession(c, meta)
if err != nil {
errCh <- err
return
}
defer c.Close()
if session.Type != SessionTypeTCP { if session.Type != SessionTypeTCP {
errCh <- io.ErrUnexpectedEOF errCh <- io.ErrUnexpectedEOF
return return
@@ -69,7 +74,7 @@ func TestCustomTablesRotation_ProbedByServer(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("encode addr: %v", err) t.Fatalf("encode addr: %v", err)
} }
if _, err := cConn.Write(addrBuf); err != nil { if err := WriteKIPMessage(cConn, KIPTypeOpenTCP, addrBuf); err != nil {
t.Fatalf("write addr: %v", err) t.Fatalf("write addr: %v", err)
} }

View File

@@ -2,19 +2,20 @@ package sudoku
import ( import (
"bufio" "bufio"
"crypto/sha256" "bytes"
"encoding/binary" "crypto/ecdh"
"crypto/rand"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"io" "io"
"net" "net"
"strings"
"sync"
"time" "time"
"github.com/metacubex/mihomo/transport/sudoku/crypto" "github.com/metacubex/mihomo/transport/sudoku/crypto"
"github.com/metacubex/mihomo/transport/sudoku/obfs/httpmask" "github.com/metacubex/mihomo/transport/sudoku/obfs/httpmask"
"github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku" "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku"
"github.com/metacubex/mihomo/log"
) )
type SessionType int type SessionType int
@@ -30,18 +31,96 @@ type ServerSession struct {
Type SessionType Type SessionType
Target string Target string
// UserHash is a stable per-key identifier derived from the handshake payload. // UserHash is a stable per-key identifier derived from the client hello payload.
// It is primarily useful for debugging / user attribution when table rotation is enabled.
UserHash string UserHash string
} }
type bufferedConn struct { type HandshakeMeta struct {
net.Conn UserHash string
r *bufio.Reader
} }
func (bc *bufferedConn) Read(p []byte) (int, error) { // SuspiciousError indicates a potential probing attempt or protocol violation.
return bc.r.Read(p) // 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 { type preBufferedConn struct {
@@ -61,6 +140,26 @@ func (p *preBufferedConn) Read(b []byte) (int, error) {
return p.Conn.Read(b) 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 { type directionalConn struct {
net.Conn net.Conn
reader io.Reader reader io.Reader
@@ -101,6 +200,26 @@ func (c *directionalConn) Close() error {
return firstErr 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 { func absInt64(v int64) int64 {
if v < 0 { if v < 0 {
return -v return -v
@@ -108,18 +227,6 @@ func absInt64(v int64) int64 {
return v return v
} }
const (
downlinkModePure byte = 0x01
downlinkModePacked byte = 0x02
)
func downlinkMode(cfg *ProtocolConfig) byte {
if cfg.EnablePureDownlink {
return downlinkModePure
}
return downlinkModePacked
}
func buildClientObfsConn(raw net.Conn, cfg *ProtocolConfig, table *sudoku.Table) net.Conn { func buildClientObfsConn(raw net.Conn, cfg *ProtocolConfig, table *sudoku.Table) net.Conn {
baseSudoku := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false) baseSudoku := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false)
if cfg.EnablePureDownlink { if cfg.EnablePureDownlink {
@@ -138,50 +245,16 @@ func buildServerObfsConn(raw net.Conn, cfg *ProtocolConfig, table *sudoku.Table,
return uplinkSudoku, newDirectionalConn(raw, uplinkSudoku, packed, packed.Flush) return uplinkSudoku, newDirectionalConn(raw, uplinkSudoku, packed, packed.Flush)
} }
func buildHandshakePayload(key string) [16]byte { func isLegacyHTTPMaskMode(mode string) bool {
var payload [16]byte switch strings.ToLower(strings.TrimSpace(mode)) {
binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Unix())) case "", "legacy":
return true
// Align with upstream: only decode hex bytes when this key is an ED25519 key material. default:
// For plain UUID/strings (even if they look like hex), hash the string bytes as-is. return false
src := []byte(key)
if _, err := crypto.RecoverPublicKey(key); err == nil {
if keyBytes, decErr := hex.DecodeString(key); decErr == nil && len(keyBytes) > 0 {
src = keyBytes
}
} }
hash := sha256.Sum256(src)
copy(payload[8:], hash[:8])
return payload
} }
func NewTable(key string, tableType string) *sudoku.Table { // ClientHandshake performs the client-side Sudoku handshake (no target request).
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) {
start := time.Now()
table, err := sudoku.NewTableWithCustom(key, tableType, customTable)
if err != nil {
return nil, err
}
log.Infoln("[Sudoku] Tables initialized (%s, custom=%v) in %v", tableType, customTable != "", time.Since(start))
return table, nil
}
func ClientAEADSeed(key string) string {
if recovered, err := crypto.RecoverPublicKey(key); err == nil {
return crypto.EncodePoint(recovered)
}
return key
}
// ClientHandshake performs the client-side Sudoku handshake (without sending target address).
func ClientHandshake(rawConn net.Conn, cfg *ProtocolConfig) (net.Conn, error) { func ClientHandshake(rawConn net.Conn, cfg *ProtocolConfig) (net.Conn, error) {
if cfg == nil { if cfg == nil {
return nil, fmt.Errorf("config is required") return nil, fmt.Errorf("config is required")
@@ -190,7 +263,7 @@ func ClientHandshake(rawConn net.Conn, cfg *ProtocolConfig) (net.Conn, error) {
return nil, fmt.Errorf("invalid config: %w", err) return nil, fmt.Errorf("invalid config: %w", err)
} }
if !cfg.DisableHTTPMask { if !cfg.DisableHTTPMask && isLegacyHTTPMaskMode(cfg.HTTPMaskMode) {
if err := httpmask.WriteRandomRequestHeaderWithPathRoot(rawConn, cfg.ServerAddress, cfg.HTTPMaskPathRoot); err != nil { if err := httpmask.WriteRandomRequestHeaderWithPathRoot(rawConn, cfg.ServerAddress, cfg.HTTPMaskPathRoot); err != nil {
return nil, fmt.Errorf("write http mask failed: %w", err) return nil, fmt.Errorf("write http mask failed: %w", err)
} }
@@ -201,32 +274,68 @@ func ClientHandshake(rawConn net.Conn, cfg *ProtocolConfig) (net.Conn, error) {
return nil, err return nil, err
} }
seed := ClientAEADSeed(cfg.Key)
obfsConn := buildClientObfsConn(rawConn, cfg, table) obfsConn := buildClientObfsConn(rawConn, cfg, table)
cConn, err := crypto.NewAEADConn(obfsConn, ClientAEADSeed(cfg.Key), cfg.AEADMethod) pskC2S, pskS2C := derivePSKDirectionalBases(seed)
rc, err := crypto.NewRecordConn(obfsConn, cfg.AEADMethod, pskC2S, pskS2C)
if err != nil { if err != nil {
return nil, fmt.Errorf("setup crypto failed: %w", err) return nil, fmt.Errorf("setup crypto failed: %w", err)
} }
handshake := buildHandshakePayload(cfg.Key) if _, err := kipHandshakeClient(rc, seed, kipUserHashFromKey(cfg.Key), KIPFeatAll); err != nil {
if _, err := cConn.Write(handshake[:]); err != nil { _ = rc.Close()
cConn.Close() return nil, err
return nil, fmt.Errorf("send handshake failed: %w", err)
}
if _, err := cConn.Write([]byte{downlinkMode(cfg)}); err != nil {
cConn.Close()
return nil, fmt.Errorf("send downlink mode failed: %w", err)
} }
return cConn, nil return rc, nil
} }
// ServerHandshake performs Sudoku server-side handshake and detects UoT preface. func readFirstSessionMessage(conn net.Conn) (*KIPMessage, error) {
func ServerHandshake(rawConn net.Conn, cfg *ProtocolConfig) (*ServerSession, 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 { if cfg == nil {
return nil, fmt.Errorf("config is required") return nil, nil, fmt.Errorf("config is required")
} }
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err) return nil, nil, fmt.Errorf("invalid config: %w", err)
} }
handshakeTimeout := time.Duration(cfg.HandshakeTimeoutSeconds) * time.Second handshakeTimeout := time.Duration(cfg.HandshakeTimeoutSeconds) * time.Second
@@ -234,116 +343,113 @@ func ServerHandshake(rawConn net.Conn, cfg *ProtocolConfig) (*ServerSession, err
handshakeTimeout = 5 * time.Second handshakeTimeout = 5 * time.Second
} }
rawConn.SetReadDeadline(time.Now().Add(handshakeTimeout))
bufReader := bufio.NewReader(rawConn) bufReader := bufio.NewReader(rawConn)
if !cfg.DisableHTTPMask { _ = rawConn.SetReadDeadline(time.Now().Add(handshakeTimeout))
if peek, err := bufReader.Peek(4); err == nil && httpmask.LooksLikeHTTPRequestStart(peek) { defer func() { _ = rawConn.SetReadDeadline(time.Time{}) }()
if _, err := httpmask.ConsumeHeader(bufReader); err != nil {
return nil, fmt.Errorf("invalid http header: %w", err) httpHeaderData, susp := maybeConsumeLegacyHTTPMask(rawConn, bufReader, cfg)
} if susp != nil {
} return nil, nil, susp
} }
selectedTable, preRead, err := selectTableByProbe(bufReader, cfg, cfg.tableCandidates()) selectedTable, preRead, err := selectTableByProbe(bufReader, cfg, cfg.tableCandidates())
if err != nil { if err != nil {
return nil, err 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} baseConn := &preBufferedConn{Conn: rawConn, buf: preRead}
bConn := &bufferedConn{Conn: baseConn, r: bufio.NewReader(baseConn)} sConn, obfsConn := buildServerObfsConn(baseConn, cfg, selectedTable, true)
sConn, obfsConn := buildServerObfsConn(bConn, cfg, selectedTable, true)
cConn, err := crypto.NewAEADConn(obfsConn, cfg.Key, cfg.AEADMethod) 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 { if err != nil {
return nil, fmt.Errorf("crypto setup failed: %w", err) return nil, nil, fmt.Errorf("setup crypto failed: %w", err)
} }
var handshakeBuf [16]byte msg, err := ReadKIPMessage(rc)
if _, err := io.ReadFull(cConn, handshakeBuf[:]); err != nil { if err != nil {
cConn.Close() return nil, nil, &SuspiciousError{Err: fmt.Errorf("handshake read failed: %w", err), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}}
return nil, fmt.Errorf("read handshake failed: %w", err) }
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}}
} }
ts := int64(binary.BigEndian.Uint64(handshakeBuf[:8])) userHashHex := hex.EncodeToString(ch.UserHash[:])
if absInt64(time.Now().Unix()-ts) > 60 { if !globalHandshakeReplay.allow(userHashHex, ch.Nonce, time.Now()) {
cConn.Close() return nil, nil, &SuspiciousError{Err: fmt.Errorf("replay"), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}}
return nil, fmt.Errorf("timestamp skew detected") }
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}}
} }
userHash := userHashFromHandshake(handshakeBuf[:])
sConn.StopRecording() sConn.StopRecording()
return rc, &HandshakeMeta{UserHash: userHashHex}, nil
}
modeBuf := []byte{0} // ReadServerSession consumes the first post-handshake KIP control message and returns the session intent.
if _, err := io.ReadFull(cConn, modeBuf); err != nil { func ReadServerSession(conn net.Conn, meta *HandshakeMeta) (*ServerSession, error) {
cConn.Close() if conn == nil {
return nil, fmt.Errorf("read downlink mode failed: %w", err) return nil, fmt.Errorf("nil conn")
} }
if modeBuf[0] != downlinkMode(cfg) { userHash := ""
cConn.Close() if meta != nil {
return nil, fmt.Errorf("downlink mode mismatch: client=%d server=%d", modeBuf[0], downlinkMode(cfg)) userHash = meta.UserHash
} }
firstByte := make([]byte, 1) first, err := readFirstSessionMessage(conn)
if _, err := io.ReadFull(cConn, firstByte); err != nil { if err != nil {
cConn.Close() return nil, err
return nil, fmt.Errorf("read first byte failed: %w", err)
} }
if firstByte[0] == MultiplexMagicByte { switch first.Type {
rawConn.SetReadDeadline(time.Time{}) case KIPTypeStartUoT:
return &ServerSession{Conn: cConn, Type: SessionTypeMultiplex, UserHash: userHash}, nil return &ServerSession{Conn: conn, Type: SessionTypeUoT, UserHash: userHash}, nil
} case KIPTypeStartMux:
return &ServerSession{Conn: conn, Type: SessionTypeMultiplex, UserHash: userHash}, nil
if firstByte[0] == UoTMagicByte { case KIPTypeOpenTCP:
version := make([]byte, 1) target, err := DecodeAddress(bytes.NewReader(first.Payload))
if _, err := io.ReadFull(cConn, version); err != nil { if err != nil {
cConn.Close() return nil, fmt.Errorf("decode target address failed: %w", err)
return nil, fmt.Errorf("read uot version failed: %w", err)
} }
if version[0] != uotVersion { return &ServerSession{Conn: conn, Type: SessionTypeTCP, Target: target, UserHash: userHash}, nil
cConn.Close() default:
return nil, fmt.Errorf("unsupported uot version: %d", version[0]) return nil, fmt.Errorf("unknown kip message: %d", first.Type)
}
rawConn.SetReadDeadline(time.Time{})
return &ServerSession{Conn: cConn, Type: SessionTypeUoT, UserHash: userHash}, nil
} }
prefixed := &preBufferedConn{Conn: cConn, buf: firstByte}
target, err := DecodeAddress(prefixed)
if err != nil {
cConn.Close()
return nil, fmt.Errorf("read target address failed: %w", err)
}
rawConn.SetReadDeadline(time.Time{})
log.Debugln("[Sudoku] incoming TCP session target: %s", target)
return &ServerSession{
Conn: prefixed,
Type: SessionTypeTCP,
Target: target,
UserHash: userHash,
}, nil
}
func GenKeyPair() (privateKey, publicKey string, err error) {
// Generate Master Key
pair, err := crypto.GenerateMasterKey()
if err != nil {
return
}
// Split the master private key to get Available Private Key
availablePrivateKey, err := crypto.SplitPrivateKey(pair.Private)
if err != nil {
return
}
privateKey = availablePrivateKey // Available Private Key for client
publicKey = crypto.EncodePoint(pair.Public) // Master Public Key for server
return
}
func userHashFromHandshake(handshakeBuf []byte) string {
if len(handshakeBuf) < 16 {
return ""
}
return hex.EncodeToString(handshakeBuf[8:16])
} }

View File

@@ -0,0 +1,73 @@
package sudoku
import (
"crypto/ecdh"
"crypto/rand"
"fmt"
"io"
"time"
"github.com/metacubex/mihomo/transport/sudoku/crypto"
)
const kipHandshakeSkew = 60 * time.Second
func kipHandshakeClient(rc *crypto.RecordConn, seed string, userHash [kipHelloUserHashSize]byte, feats uint32) (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 := &KIPClientHello{
Timestamp: time.Now(),
UserHash: userHash,
Nonce: nonce,
ClientPub: clientPub,
Features: feats,
}
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
}

View File

@@ -124,13 +124,18 @@ func runPackedTCPSession(id int, cfg *ProtocolConfig, errCh chan<- error) {
// Server side // Server side
go func() { go func() {
session, err := ServerHandshake(serverConn, cfg) c, meta, err := ServerHandshake(serverConn, cfg)
if err != nil { if err != nil {
errCh <- fmt.Errorf("server handshake tcp: %w", err) errCh <- fmt.Errorf("server handshake tcp: %w", err)
return return
} }
defer session.Conn.Close() defer c.Close()
session, err := ReadServerSession(c, meta)
if err != nil {
errCh <- fmt.Errorf("server read session tcp: %w", err)
return
}
if session.Type != SessionTypeTCP { if session.Type != SessionTypeTCP {
errCh <- fmt.Errorf("unexpected session type: %v", session.Type) errCh <- fmt.Errorf("unexpected session type: %v", session.Type)
return return
@@ -159,8 +164,8 @@ func runPackedTCPSession(id int, cfg *ProtocolConfig, errCh chan<- error) {
errCh <- fmt.Errorf("encode address: %w", err) errCh <- fmt.Errorf("encode address: %w", err)
return return
} }
if _, err := cConn.Write(addrBuf); err != nil { if err := WriteKIPMessage(cConn, KIPTypeOpenTCP, addrBuf); err != nil {
errCh <- fmt.Errorf("client send addr: %w", err) errCh <- fmt.Errorf("client send open tcp: %w", err)
return return
} }
@@ -182,13 +187,18 @@ func runPackedUoTSession(id int, cfg *ProtocolConfig, errCh chan<- error) {
// Server side // Server side
go func() { go func() {
session, err := ServerHandshake(serverConn, cfg) c, meta, err := ServerHandshake(serverConn, cfg)
if err != nil { if err != nil {
errCh <- fmt.Errorf("server handshake uot: %w", err) errCh <- fmt.Errorf("server handshake uot: %w", err)
return return
} }
defer session.Conn.Close() defer c.Close()
session, err := ReadServerSession(c, meta)
if err != nil {
errCh <- fmt.Errorf("server read session uot: %w", err)
return
}
if session.Type != SessionTypeUoT { if session.Type != SessionTypeUoT {
errCh <- fmt.Errorf("unexpected session type: %v", session.Type) errCh <- fmt.Errorf("unexpected session type: %v", session.Type)
return return
@@ -208,8 +218,8 @@ func runPackedUoTSession(id int, cfg *ProtocolConfig, errCh chan<- error) {
} }
defer cConn.Close() defer cConn.Close()
if err := WritePreface(cConn); err != nil { if err := WriteKIPMessage(cConn, KIPTypeStartUoT, nil); err != nil {
errCh <- fmt.Errorf("client write preface: %w", err) errCh <- fmt.Errorf("client start uot: %w", err)
return return
} }

View File

@@ -15,6 +15,14 @@ type HTTPMaskTunnelServer struct {
} }
func NewHTTPMaskTunnelServer(cfg *ProtocolConfig) *HTTPMaskTunnelServer { 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 { if cfg == nil {
return &HTTPMaskTunnelServer{} return &HTTPMaskTunnelServer{}
} }
@@ -22,11 +30,13 @@ func NewHTTPMaskTunnelServer(cfg *ProtocolConfig) *HTTPMaskTunnelServer {
var ts *httpmask.TunnelServer var ts *httpmask.TunnelServer
if !cfg.DisableHTTPMask { if !cfg.DisableHTTPMask {
switch strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) { switch strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) {
case "stream", "poll", "auto": case "stream", "poll", "auto", "ws":
ts = httpmask.NewTunnelServer(httpmask.TunnelServerOptions{ ts = httpmask.NewTunnelServer(httpmask.TunnelServerOptions{
Mode: cfg.HTTPMaskMode, Mode: cfg.HTTPMaskMode,
PathRoot: cfg.HTTPMaskPathRoot, PathRoot: cfg.HTTPMaskPathRoot,
AuthKey: ClientAEADSeed(cfg.Key), AuthKey: ServerAEADSeed(cfg.Key),
// When upstream fallback is enabled, preserve rejected HTTP requests for the caller.
PassThroughOnReject: passThroughOnReject,
}) })
} }
} }
@@ -62,6 +72,14 @@ func (s *HTTPMaskTunnelServer) WrapConn(rawConn net.Conn) (handshakeConn net.Con
case httpmask.HandleStartTunnel: case httpmask.HandleStartTunnel:
inner := *s.cfg inner := *s.cfg
inner.DisableHTTPMask = true 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 return c, &inner, false, nil
default: default:
return nil, nil, true, nil return nil, nil, true, nil
@@ -70,7 +88,7 @@ func (s *HTTPMaskTunnelServer) WrapConn(rawConn net.Conn) (handshakeConn net.Con
type TunnelDialer func(ctx context.Context, network, addr string) (net.Conn, error) type TunnelDialer func(ctx context.Context, network, addr string) (net.Conn, error)
// DialHTTPMaskTunnel dials a CDN-capable HTTP tunnel (stream/poll/auto) and returns a stream carrying raw Sudoku bytes. // 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) { func DialHTTPMaskTunnel(ctx context.Context, serverAddress string, cfg *ProtocolConfig, dial TunnelDialer, upgrade func(net.Conn) (net.Conn, error)) (net.Conn, error) {
if cfg == nil { if cfg == nil {
return nil, fmt.Errorf("config is required") return nil, fmt.Errorf("config is required")
@@ -79,7 +97,7 @@ func DialHTTPMaskTunnel(ctx context.Context, serverAddress string, cfg *Protocol
return nil, fmt.Errorf("http mask is disabled") return nil, fmt.Errorf("http mask is disabled")
} }
switch strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) { switch strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) {
case "stream", "poll", "auto": case "stream", "poll", "auto", "ws":
default: default:
return nil, fmt.Errorf("http-mask-mode=%q does not use http tunnel", cfg.HTTPMaskMode) return nil, fmt.Errorf("http-mask-mode=%q does not use http tunnel", cfg.HTTPMaskMode)
} }
@@ -94,64 +112,3 @@ func DialHTTPMaskTunnel(ctx context.Context, serverAddress string, cfg *Protocol
DialContext: dial, DialContext: dial,
}) })
} }
type HTTPMaskTunnelClient struct {
mode string
pathRoot string
authKey string
client *httpmask.TunnelClient
}
func NewHTTPMaskTunnelClient(serverAddress string, cfg *ProtocolConfig, dial TunnelDialer) (*HTTPMaskTunnelClient, 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":
default:
return nil, fmt.Errorf("http-mask-mode=%q does not use http tunnel", cfg.HTTPMaskMode)
}
switch strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMultiplex)) {
case "auto", "on":
default:
return nil, fmt.Errorf("http-mask-multiplex=%q does not enable reuse", cfg.HTTPMaskMultiplex)
}
c, err := httpmask.NewTunnelClient(serverAddress, httpmask.TunnelClientOptions{
TLSEnabled: cfg.HTTPMaskTLSEnabled,
HostOverride: cfg.HTTPMaskHost,
DialContext: dial,
})
if err != nil {
return nil, err
}
return &HTTPMaskTunnelClient{
mode: cfg.HTTPMaskMode,
pathRoot: cfg.HTTPMaskPathRoot,
authKey: ClientAEADSeed(cfg.Key),
client: c,
}, nil
}
func (c *HTTPMaskTunnelClient) Dial(ctx context.Context, upgrade func(net.Conn) (net.Conn, error)) (net.Conn, error) {
if c == nil || c.client == nil {
return nil, fmt.Errorf("nil httpmask tunnel client")
}
return c.client.DialTunnel(ctx, httpmask.TunnelDialOptions{
Mode: c.mode,
PathRoot: c.pathRoot,
AuthKey: c.authKey,
Upgrade: upgrade,
})
}
func (c *HTTPMaskTunnelClient) CloseIdleConnections() {
if c == nil || c.client == nil {
return
}
c.client.CloseIdleConnections()
}

View File

@@ -61,7 +61,7 @@ func startTunnelServer(t *testing.T, cfg *ProtocolConfig, handle func(*ServerSes
return return
} }
session, err := ServerHandshake(handshakeConn, handshakeCfg) cConn, meta, err := ServerHandshake(handshakeConn, handshakeCfg)
if err != nil { if err != nil {
_ = handshakeConn.Close() _ = handshakeConn.Close()
if handshakeConn != conn { if handshakeConn != conn {
@@ -70,8 +70,13 @@ func startTunnelServer(t *testing.T, cfg *ProtocolConfig, handle func(*ServerSes
errC <- err errC <- err
return return
} }
defer session.Conn.Close() defer cConn.Close()
session, err := ReadServerSession(cConn, meta)
if err != nil {
errC <- err
return
}
if handleErr := handle(session); handleErr != nil { if handleErr := handle(session); handleErr != nil {
errC <- handleErr errC <- handleErr
} }
@@ -172,7 +177,7 @@ func TestHTTPMaskTunnel_Stream_TCPRoundTrip(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("encode addr: %v", err) t.Fatalf("encode addr: %v", err)
} }
if _, err := cConn.Write(addrBuf); err != nil { if err := WriteKIPMessage(cConn, KIPTypeOpenTCP, addrBuf); err != nil {
t.Fatalf("write addr: %v", err) t.Fatalf("write addr: %v", err)
} }
@@ -239,8 +244,8 @@ func TestHTTPMaskTunnel_Poll_UoTRoundTrip(t *testing.T) {
} }
defer cConn.Close() defer cConn.Close()
if err := WritePreface(cConn); err != nil { if err := WriteKIPMessage(cConn, KIPTypeStartUoT, nil); err != nil {
t.Fatalf("write preface: %v", err) t.Fatalf("start uot: %v", err)
} }
if err := WriteDatagram(cConn, target, payload); err != nil { if err := WriteDatagram(cConn, target, payload); err != nil {
t.Fatalf("write datagram: %v", err) t.Fatalf("write datagram: %v", err)
@@ -305,7 +310,68 @@ func TestHTTPMaskTunnel_Auto_TCPRoundTrip(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("encode addr: %v", err) t.Fatalf("encode addr: %v", err)
} }
if _, err := cConn.Write(addrBuf); err != nil { if err := WriteKIPMessage(cConn, KIPTypeOpenTCP, addrBuf); err != nil {
t.Fatalf("write addr: %v", err)
}
buf := make([]byte, 2)
if _, err := io.ReadFull(cConn, buf); err != nil {
t.Fatalf("read: %v", err)
}
if string(buf) != "ok" {
t.Fatalf("unexpected payload: %q", buf)
}
stop()
for err := range errCh {
t.Fatalf("server error: %v", err)
}
}
func TestHTTPMaskTunnel_WS_TCPRoundTrip(t *testing.T) {
key := "tunnel-ws-key"
target := "1.1.1.1:80"
serverCfg := newTunnelTestTable(t, key)
serverCfg.HTTPMaskMode = "ws"
addr, stop, errCh := startTunnelServer(t, serverCfg, func(s *ServerSession) error {
if s.Type != SessionTypeTCP {
return fmt.Errorf("unexpected session type: %v", s.Type)
}
if s.Target != target {
return fmt.Errorf("target mismatch: %s", s.Target)
}
_, _ = s.Conn.Write([]byte("ok"))
return nil
})
defer stop()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
clientCfg := *serverCfg
clientCfg.ServerAddress = addr
tunnelConn, err := DialHTTPMaskTunnel(ctx, clientCfg.ServerAddress, &clientCfg, (&net.Dialer{}).DialContext, nil)
if err != nil {
t.Fatalf("dial tunnel: %v", err)
}
defer tunnelConn.Close()
handshakeCfg := clientCfg
handshakeCfg.DisableHTTPMask = true
cConn, err := ClientHandshake(tunnelConn, &handshakeCfg)
if err != nil {
t.Fatalf("client handshake: %v", err)
}
defer cConn.Close()
addrBuf, err := EncodeAddress(target)
if err != nil {
t.Fatalf("encode addr: %v", err)
}
if err := WriteKIPMessage(cConn, KIPTypeOpenTCP, addrBuf); err != nil {
t.Fatalf("write addr: %v", err) t.Fatalf("write addr: %v", err)
} }
@@ -406,7 +472,7 @@ func TestHTTPMaskTunnel_Soak_Concurrent(t *testing.T) {
runErr <- fmt.Errorf("encode addr: %w", err) runErr <- fmt.Errorf("encode addr: %w", err)
return return
} }
if _, err := cConn.Write(addrBuf); err != nil { if err := WriteKIPMessage(cConn, KIPTypeOpenTCP, addrBuf); err != nil {
runErr <- fmt.Errorf("write addr: %w", err) runErr <- fmt.Errorf("write addr: %w", err)
return return
} }

97
transport/sudoku/init.go Normal file
View File

@@ -0,0 +1,97 @@
package sudoku
import (
"encoding/hex"
"fmt"
"strings"
"github.com/metacubex/edwards25519"
"github.com/metacubex/mihomo/transport/sudoku/crypto"
"github.com/metacubex/mihomo/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:
// - split private key: 64 bytes hex (r||k)
// - master private scalar: 32 bytes hex (x)
// - PSK string: non-hex
//
// We intentionally do NOT treat a 32-byte hex as a public key here; the client is expected
// to carry private material. Server-side should use ServerAEADSeed for public keys.
switch len(b) {
case 64:
case 32:
default:
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
}

View File

@@ -0,0 +1,44 @@
package sudoku
import (
"crypto/rand"
"encoding/hex"
"testing"
"github.com/metacubex/edwards25519"
"github.com/stretchr/testify/require"
)
func TestClientAEADSeed_IsStableForPrivAndPub(t *testing.T) {
for i := 0; i < 64; i++ {
priv, pub, err := GenKeyPair()
require.NoError(t, err)
require.Equal(t, pub, ClientAEADSeed(priv))
require.Equal(t, pub, ServerAEADSeed(pub))
require.Equal(t, pub, ServerAEADSeed(priv))
}
}
func TestClientAEADSeed_Supports32ByteMasterScalar(t *testing.T) {
var seed [64]byte
_, err := rand.Read(seed[:])
require.NoError(t, err)
s, err := edwards25519.NewScalar().SetUniformBytes(seed[:])
require.NoError(t, err)
keyHex := hex.EncodeToString(s.Bytes())
require.Len(t, keyHex, 64)
require.NotEqual(t, keyHex, ClientAEADSeed(keyHex))
require.Equal(t, ClientAEADSeed(keyHex), ServerAEADSeed(ClientAEADSeed(keyHex)))
}
func TestServerAEADSeed_LeavesPublicKeyAsIs(t *testing.T) {
for i := 0; i < 64; i++ {
priv, pub, err := GenKeyPair()
require.NoError(t, err)
require.Equal(t, pub, ServerAEADSeed(pub))
require.Equal(t, pub, ServerAEADSeed(priv))
}
}

206
transport/sudoku/kip.go Normal file
View File

@@ -0,0 +1,206 @@
package sudoku
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"io"
"strings"
"time"
)
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
)
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)))
if err := writeFull(w, hdr[:]); err != nil {
return err
}
if len(payload) == 0 {
return nil
}
return writeFull(w, 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
}
type KIPServerHello struct {
Nonce [kipHelloNonceSize]byte
ServerPub [kipHelloPubSize]byte
SelectedFeats uint32
}
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[:])
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])
return &h, 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
}

View File

@@ -10,19 +10,14 @@ import (
"github.com/metacubex/mihomo/transport/sudoku/multiplex" "github.com/metacubex/mihomo/transport/sudoku/multiplex"
) )
const ( // StartMultiplexClient upgrades an already-handshaked Sudoku tunnel into a multiplex session.
MultiplexMagicByte byte = multiplex.MagicByte
MultiplexVersion byte = multiplex.Version
)
// StartMultiplexClient writes the multiplex preface and upgrades an already-handshaked Sudoku tunnel into a multiplex session.
func StartMultiplexClient(conn net.Conn) (*MultiplexClient, error) { func StartMultiplexClient(conn net.Conn) (*MultiplexClient, error) {
if conn == nil { if conn == nil {
return nil, fmt.Errorf("nil conn") return nil, fmt.Errorf("nil conn")
} }
if err := multiplex.WritePreface(conn); err != nil { if err := WriteKIPMessage(conn, KIPTypeStartMux, nil); err != nil {
return nil, fmt.Errorf("write multiplex preface failed: %w", err) return nil, fmt.Errorf("write mux start failed: %w", err)
} }
sess, err := multiplex.NewClientSession(conn) sess, err := multiplex.NewClientSession(conn)
@@ -77,20 +72,10 @@ func (c *MultiplexClient) IsClosed() bool {
} }
// AcceptMultiplexServer upgrades a server-side, already-handshaked Sudoku connection into a multiplex session. // AcceptMultiplexServer upgrades a server-side, already-handshaked Sudoku connection into a multiplex session.
//
// The caller must have already consumed the multiplex magic byte (MultiplexMagicByte). This function consumes the
// multiplex version byte and starts the session.
func AcceptMultiplexServer(conn net.Conn) (*MultiplexServer, error) { func AcceptMultiplexServer(conn net.Conn) (*MultiplexServer, error) {
if conn == nil { if conn == nil {
return nil, fmt.Errorf("nil conn") return nil, fmt.Errorf("nil conn")
} }
v, err := multiplex.ReadVersion(conn)
if err != nil {
return nil, err
}
if err := multiplex.ValidateVersion(v); err != nil {
return nil, err
}
sess, err := multiplex.NewServerSession(conn) sess, err := multiplex.NewServerSession(conn)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -1,39 +0,0 @@
package multiplex
import (
"fmt"
"io"
)
const (
// MagicByte marks a Sudoku tunnel connection that will switch into multiplex mode.
// It is sent after the Sudoku handshake + downlink mode byte.
//
// Keep it distinct from UoTMagicByte and address type bytes.
MagicByte byte = 0xED
Version byte = 0x01
)
func WritePreface(w io.Writer) error {
if w == nil {
return fmt.Errorf("nil writer")
}
_, err := w.Write([]byte{MagicByte, Version})
return err
}
func ReadVersion(r io.Reader) (byte, error) {
var b [1]byte
if _, err := io.ReadFull(r, b[:]); err != nil {
return 0, err
}
return b[0], nil
}
func ValidateVersion(v byte) error {
if v != Version {
return fmt.Errorf("unsupported multiplex version: %d", v)
}
return nil
}

View File

@@ -18,7 +18,6 @@ func TestUserHash_StableAcrossTableRotation(t *testing.T) {
sudokuobfs.NewTable("seed-b", "prefer_ascii"), sudokuobfs.NewTable("seed-b", "prefer_ascii"),
} }
key := "userhash-stability-key" key := "userhash-stability-key"
target := "example.com:80"
serverCfg := DefaultConfig() serverCfg := DefaultConfig()
serverCfg.Key = key serverCfg.Key = key
@@ -48,13 +47,16 @@ func TestUserHash_StableAcrossTableRotation(t *testing.T) {
} }
go func(conn net.Conn) { go func(conn net.Conn) {
defer conn.Close() defer conn.Close()
session, err := ServerHandshake(conn, serverCfg) _, meta, err := ServerHandshake(conn, serverCfg)
if err != nil { if err != nil {
errCh <- err errCh <- err
return return
} }
defer session.Conn.Close() if meta == nil || meta.UserHash == "" {
hashCh <- session.UserHash errCh <- io.ErrUnexpectedEOF
return
}
hashCh <- meta.UserHash
}(c) }(c)
} }
}() }()
@@ -77,15 +79,6 @@ func TestUserHash_StableAcrossTableRotation(t *testing.T) {
t.Fatalf("handshake %d: %v", i, err) t.Fatalf("handshake %d: %v", i, err)
} }
addrBuf, err := EncodeAddress(target)
if err != nil {
_ = cConn.Close()
t.Fatalf("encode addr %d: %v", i, err)
}
if _, err := cConn.Write(addrBuf); err != nil {
_ = cConn.Close()
t.Fatalf("write addr %d: %v", i, err)
}
_ = cConn.Close() _ = cConn.Close()
} }
@@ -145,18 +138,22 @@ func TestMultiplex_TCP_Echo(t *testing.T) {
} }
defer raw.Close() defer raw.Close()
session, err := ServerHandshake(raw, serverCfg) c, meta, err := ServerHandshake(raw, serverCfg)
if err != nil { if err != nil {
return return
} }
atomic.AddInt64(&handshakes, 1) atomic.AddInt64(&handshakes, 1)
session, err := ReadServerSession(c, meta)
if err != nil {
return
}
if session.Type != SessionTypeMultiplex { if session.Type != SessionTypeMultiplex {
_ = session.Conn.Close() _ = c.Close()
return return
} }
mux, err := AcceptMultiplexServer(session.Conn) mux, err := AcceptMultiplexServer(c)
if err != nil { if err != nil {
return return
} }
@@ -240,21 +237,3 @@ func TestMultiplex_TCP_Echo(t *testing.T) {
t.Fatalf("unexpected stream count: %d", got) t.Fatalf("unexpected stream count: %d", got)
} }
} }
func TestMultiplex_Boundary_InvalidVersion(t *testing.T) {
client, server := net.Pipe()
t.Cleanup(func() { _ = client.Close() })
t.Cleanup(func() { _ = server.Close() })
errCh := make(chan error, 1)
go func() {
_, err := AcceptMultiplexServer(server)
errCh <- err
}()
// AcceptMultiplexServer expects the magic byte to have been consumed already; write a bad version byte.
_, _ = client.Write([]byte{0xFF})
if err := <-errCh; err == nil {
t.Fatalf("expected error")
}
}

View File

@@ -6,9 +6,10 @@ import (
"crypto/subtle" "crypto/subtle"
"encoding/base64" "encoding/base64"
"encoding/binary" "encoding/binary"
"github.com/metacubex/http"
"strings" "strings"
"time" "time"
"github.com/metacubex/http"
) )
const ( const (

View File

@@ -16,6 +16,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"syscall"
"time" "time"
"github.com/metacubex/mihomo/component/ca" "github.com/metacubex/mihomo/component/ca"
@@ -32,6 +33,7 @@ const (
TunnelModeStream TunnelMode = "stream" TunnelModeStream TunnelMode = "stream"
TunnelModePoll TunnelMode = "poll" TunnelModePoll TunnelMode = "poll"
TunnelModeAuto TunnelMode = "auto" TunnelModeAuto TunnelMode = "auto"
TunnelModeWS TunnelMode = "ws"
) )
func normalizeTunnelMode(mode string) TunnelMode { func normalizeTunnelMode(mode string) TunnelMode {
@@ -44,6 +46,8 @@ func normalizeTunnelMode(mode string) TunnelMode {
return TunnelModePoll return TunnelModePoll
case string(TunnelModeAuto): case string(TunnelModeAuto):
return TunnelModeAuto return TunnelModeAuto
case string(TunnelModeWS):
return TunnelModeWS
default: default:
// Be conservative: unknown => legacy // Be conservative: unknown => legacy
return TunnelModeLegacy return TunnelModeLegacy
@@ -88,7 +92,6 @@ type TunnelClientOptions struct {
} }
type TunnelClient struct { type TunnelClient struct {
client *http.Client
transport *http.Transport transport *http.Transport
target httpClientTarget target httpClientTarget
} }
@@ -105,7 +108,6 @@ func NewTunnelClient(serverAddress string, opts TunnelClientOptions) (*TunnelCli
} }
return &TunnelClient{ return &TunnelClient{
client: &http.Client{Transport: transport},
transport: transport, transport: transport,
target: target, target: target,
}, nil }, nil
@@ -119,7 +121,7 @@ func (c *TunnelClient) CloseIdleConnections() {
} }
func (c *TunnelClient) DialTunnel(ctx context.Context, opts TunnelDialOptions) (net.Conn, error) { func (c *TunnelClient) DialTunnel(ctx context.Context, opts TunnelDialOptions) (net.Conn, error) {
if c == nil || c.client == nil { if c == nil || c.transport == nil {
return nil, fmt.Errorf("nil tunnel client") return nil, fmt.Errorf("nil tunnel client")
} }
tm := normalizeTunnelMode(opts.Mode) tm := normalizeTunnelMode(opts.Mode)
@@ -127,25 +129,31 @@ func (c *TunnelClient) DialTunnel(ctx context.Context, opts TunnelDialOptions) (
return nil, fmt.Errorf("legacy mode does not use http tunnel") return nil, fmt.Errorf("legacy mode does not use http tunnel")
} }
// Create a per-dial client while sharing the underlying Transport for connection reuse.
// This matches upstream behavior and avoids potential client-level concurrency pitfalls.
client := &http.Client{Transport: c.transport}
switch tm { switch tm {
case TunnelModeStream: case TunnelModeStream:
return dialStreamWithClient(ctx, c.client, c.target, opts) return dialStreamWithClient(ctx, client, c.target, opts)
case TunnelModePoll: case TunnelModePoll:
return dialPollWithClient(ctx, c.client, c.target, opts) return dialPollWithClient(ctx, client, c.target, opts)
case TunnelModeWS:
return nil, fmt.Errorf("ws mode does not support TunnelClient reuse")
case TunnelModeAuto: case TunnelModeAuto:
streamCtx, cancelX := context.WithTimeout(ctx, 3*time.Second) streamCtx, cancelX := context.WithTimeout(ctx, 3*time.Second)
c1, errX := dialStreamWithClient(streamCtx, c.client, c.target, opts) c1, errX := dialStreamWithClient(streamCtx, client, c.target, opts)
cancelX() cancelX()
if errX == nil { if errX == nil {
return c1, nil return c1, nil
} }
c2, errP := dialPollWithClient(ctx, c.client, c.target, opts) c2, errP := dialPollWithClient(ctx, client, c.target, opts)
if errP == nil { if errP == nil {
return c2, nil return c2, nil
} }
return nil, fmt.Errorf("auto tunnel failed: stream: %v; poll: %w", errX, errP) return nil, fmt.Errorf("auto tunnel failed: stream: %v; poll: %w", errX, errP)
default: default:
return dialStreamWithClient(ctx, c.client, c.target, opts) return dialStreamWithClient(ctx, client, c.target, opts)
} }
} }
@@ -166,6 +174,8 @@ func DialTunnel(ctx context.Context, serverAddress string, opts TunnelDialOption
return dialStreamFn(ctx, serverAddress, opts) return dialStreamFn(ctx, serverAddress, opts)
case TunnelModePoll: case TunnelModePoll:
return dialPollFn(ctx, serverAddress, opts) return dialPollFn(ctx, serverAddress, opts)
case TunnelModeWS:
return dialWS(ctx, serverAddress, opts)
case TunnelModeAuto: case TunnelModeAuto:
// "stream" can hang on some CDNs that buffer uploads until request body completes. // "stream" can hang on some CDNs that buffer uploads until request body completes.
// Keep it on a short leash so we can fall back to poll within the caller's deadline. // Keep it on a short leash so we can fall back to poll within the caller's deadline.
@@ -306,6 +316,36 @@ type sessionDialInfo struct {
auth *tunnelAuth auth *tunnelAuth
} }
type httpStatusError struct {
code int
status string
}
func (e *httpStatusError) Error() string {
if e == nil {
return "bad status"
}
if e.status != "" {
return "bad status: " + e.status
}
return "bad status"
}
func isRetryableStatusCode(code int) bool {
return code == http.StatusRequestTimeout || code == http.StatusTooManyRequests || code >= 500
}
type idleConnCloser interface{ CloseIdleConnections() }
func closeIdleConnections(client *http.Client) {
if client == nil || client.Transport == nil {
return
}
if c, ok := client.Transport.(idleConnCloser); ok {
c.CloseIdleConnections()
}
}
func dialSessionWithClient(ctx context.Context, client *http.Client, target httpClientTarget, mode TunnelMode, opts TunnelDialOptions) (*sessionDialInfo, error) { func dialSessionWithClient(ctx context.Context, client *http.Client, target httpClientTarget, mode TunnelMode, opts TunnelDialOptions) (*sessionDialInfo, error) {
if client == nil { if client == nil {
return nil, fmt.Errorf("nil http client") return nil, fmt.Errorf("nil http client")
@@ -313,25 +353,61 @@ func dialSessionWithClient(ctx context.Context, client *http.Client, target http
auth := newTunnelAuth(opts.AuthKey, 0) auth := newTunnelAuth(opts.AuthKey, 0)
authorizeURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: joinPathRoot(opts.PathRoot, "/session")}).String() authorizeURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: joinPathRoot(opts.PathRoot, "/session")}).String()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, authorizeURL, nil)
if err != nil {
return nil, err
}
req.Host = target.headerHost
applyTunnelHeaders(req.Header, target.headerHost, mode)
applyTunnelAuth(req, auth, mode, http.MethodGet, "/session")
resp, err := client.Do(req) var bodyBytes []byte
if err != nil { for attempt := 0; ; attempt++ {
return nil, err req, err := http.NewRequestWithContext(ctx, http.MethodGet, authorizeURL, nil)
} if err != nil {
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024)) return nil, err
_ = resp.Body.Close() }
if err != nil { req.Host = target.headerHost
return nil, err applyTunnelHeaders(req.Header, target.headerHost, mode)
} applyTunnelAuth(req, auth, mode, http.MethodGet, "/session")
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s authorize bad status: %s (%s)", mode, resp.Status, strings.TrimSpace(string(bodyBytes))) resp, err := client.Do(req)
if err != nil {
// Transient failure on reused keep-alive conns (multiplex=auto). Retry a few times.
if attempt < 2 && (isDialError(err) || isRetryableRequestError(err)) {
closeIdleConnections(client)
select {
case <-time.After(25 * time.Millisecond):
continue
case <-ctx.Done():
return nil, err
}
}
return nil, err
}
bodyBytes, err = io.ReadAll(io.LimitReader(resp.Body, 4*1024))
_ = resp.Body.Close()
if err != nil {
if attempt < 2 && isRetryableRequestError(err) {
closeIdleConnections(client)
select {
case <-time.After(25 * time.Millisecond):
continue
case <-ctx.Done():
return nil, err
}
}
return nil, err
}
if resp.StatusCode != http.StatusOK {
// Retry some transient proxy/CDN errors.
if attempt < 2 && resp.StatusCode >= 500 {
closeIdleConnections(client)
select {
case <-time.After(25 * time.Millisecond):
continue
case <-ctx.Done():
return nil, fmt.Errorf("%s authorize bad status: %s (%s)", mode, resp.Status, strings.TrimSpace(string(bodyBytes)))
}
}
return nil, fmt.Errorf("%s authorize bad status: %s (%s)", mode, resp.Status, strings.TrimSpace(string(bodyBytes)))
}
break
} }
token, err := parseTunnelToken(bodyBytes) token, err := parseTunnelToken(bodyBytes)
@@ -544,9 +620,8 @@ type streamSplitConn struct {
auth *tunnelAuth auth *tunnelAuth
} }
func (c *streamSplitConn) Close() error { func (c *streamSplitConn) closeWithError(err error) error {
_ = c.closeWithError(io.ErrClosedPipe) _ = c.queuedConn.closeWithError(err)
if c.cancel != nil { if c.cancel != nil {
c.cancel() c.cancel()
} }
@@ -554,6 +629,8 @@ func (c *streamSplitConn) Close() error {
return nil return nil
} }
func (c *streamSplitConn) Close() error { return c.closeWithError(io.ErrClosedPipe) }
func newStreamSplitConnFromInfo(info *sessionDialInfo) *streamSplitConn { func newStreamSplitConnFromInfo(info *sessionDialInfo) *streamSplitConn {
if info == nil { if info == nil {
return nil return nil
@@ -659,7 +736,7 @@ func (c *streamSplitConn) pullLoop() {
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, c.pullURL, nil) req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, c.pullURL, nil)
if err != nil { if err != nil {
cancel() cancel()
_ = c.Close() _ = c.closeWithError(fmt.Errorf("stream pull build request failed: %w", err))
return return
} }
req.Host = c.headerHost req.Host = c.headerHost
@@ -669,8 +746,9 @@ func (c *streamSplitConn) pullLoop() {
resp, err := c.client.Do(req) resp, err := c.client.Do(req)
if err != nil { if err != nil {
cancel() cancel()
if isDialError(err) && dialRetry < maxDialRetry { if (isDialError(err) || isRetryableRequestError(err)) && dialRetry < maxDialRetry {
dialRetry++ dialRetry++
closeIdleConnections(c.client)
select { select {
case <-time.After(backoff): case <-time.After(backoff):
case <-c.closed: case <-c.closed:
@@ -682,16 +760,33 @@ func (c *streamSplitConn) pullLoop() {
} }
continue continue
} }
_ = c.Close() _ = c.closeWithError(fmt.Errorf("stream pull request failed: %w", err))
return return
} }
dialRetry = 0 dialRetry = 0
backoff = minBackoff backoff = minBackoff
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
if isRetryableStatusCode(resp.StatusCode) && dialRetry < maxDialRetry {
dialRetry++
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024))
_ = resp.Body.Close()
cancel()
closeIdleConnections(c.client)
select {
case <-time.After(backoff):
case <-c.closed:
return
}
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
continue
}
_ = resp.Body.Close() _ = resp.Body.Close()
cancel() cancel()
_ = c.Close() _ = c.closeWithError(fmt.Errorf("stream pull bad status: %s", resp.Status))
return return
} }
@@ -717,7 +812,12 @@ func (c *streamSplitConn) pullLoop() {
// Long-poll ended; retry. // Long-poll ended; retry.
break break
} }
_ = c.Close() // Some environments may sporadically reset the HTTP connection under load; treat
// it as an ended long-poll and retry instead of tearing down the whole tunnel.
if errors.Is(rerr, io.ErrUnexpectedEOF) || isRetryableRequestError(rerr) {
break
}
_ = c.closeWithError(fmt.Errorf("stream pull read failed: %w", rerr))
return return
} }
} }
@@ -735,8 +835,13 @@ func (c *streamSplitConn) pullLoop() {
func (c *streamSplitConn) pushLoop() { func (c *streamSplitConn) pushLoop() {
const ( const (
maxBatchBytes = 256 * 1024 // Batching is critical for stability under high concurrency: every flush is a new TCP
flushInterval = 5 * time.Millisecond // connection in HTTP/1.1, and too many tiny uploads can overwhelm the accept backlog,
// causing sporadic RSTs (connection reset by peer).
//
// Keep this below the server-side maxUploadBytes limit in streamPush().
maxBatchBytes = 512 * 1024
flushInterval = 25 * time.Millisecond
requestTimeout = 20 * time.Second requestTimeout = 20 * time.Second
maxDialRetry = 12 maxDialRetry = 12
minBackoff = 10 * time.Millisecond minBackoff = 10 * time.Millisecond
@@ -754,12 +859,18 @@ func (c *streamSplitConn) pushLoop() {
return nil return nil
} }
payload := buf.Bytes()
reqCtx, cancel := context.WithTimeout(c.ctx, requestTimeout) reqCtx, cancel := context.WithTimeout(c.ctx, requestTimeout)
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.pushURL, bytes.NewReader(buf.Bytes())) req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.pushURL, bytes.NewReader(payload))
if err != nil { if err != nil {
cancel() cancel()
return err return err
} }
// Be explicit: some http client forks won't auto-populate GetBody, which makes POST retries on stale
// keep-alive connections flaky under multiplex=auto.
req.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(payload)), nil
}
req.Host = c.headerHost req.Host = c.headerHost
applyTunnelHeaders(req.Header, c.headerHost, TunnelModeStream) applyTunnelHeaders(req.Header, c.headerHost, TunnelModeStream)
applyTunnelAuth(req, c.auth, TunnelModeStream, http.MethodPost, "/api/v1/upload") applyTunnelAuth(req, c.auth, TunnelModeStream, http.MethodPost, "/api/v1/upload")
@@ -774,7 +885,7 @@ func (c *streamSplitConn) pushLoop() {
_ = resp.Body.Close() _ = resp.Body.Close()
cancel() cancel()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bad status: %s", resp.Status) return &httpStatusError{code: resp.StatusCode, status: resp.Status}
} }
buf.Reset() buf.Reset()
@@ -787,8 +898,22 @@ func (c *streamSplitConn) pushLoop() {
for { for {
if err := flush(); err == nil { if err := flush(); err == nil {
return nil return nil
} else if isDialError(err) && dialRetry < maxDialRetry { } else if se := (*httpStatusError)(nil); errors.As(err, &se) && isRetryableStatusCode(se.code) && dialRetry < maxDialRetry {
dialRetry++ dialRetry++
closeIdleConnections(c.client)
select {
case <-time.After(backoff):
case <-c.closed:
return io.ErrClosedPipe
}
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
continue
} else if (isDialError(err) || isRetryableRequestError(err)) && dialRetry < maxDialRetry {
dialRetry++
closeIdleConnections(c.client)
select { select {
case <-time.After(backoff): case <-time.After(backoff):
case <-c.closed: case <-c.closed:
@@ -829,7 +954,7 @@ func (c *streamSplitConn) pushLoop() {
} }
if buf.Len()+len(b) > maxBatchBytes { if buf.Len()+len(b) > maxBatchBytes {
if err := flushWithRetry(); err != nil { if err := flushWithRetry(); err != nil {
_ = c.Close() _ = c.closeWithError(fmt.Errorf("stream push flush failed: %w", err))
return return
} }
resetTimer() resetTimer()
@@ -837,14 +962,14 @@ func (c *streamSplitConn) pushLoop() {
_, _ = buf.Write(b) _, _ = buf.Write(b)
if buf.Len() >= maxBatchBytes { if buf.Len() >= maxBatchBytes {
if err := flushWithRetry(); err != nil { if err := flushWithRetry(); err != nil {
_ = c.Close() _ = c.closeWithError(fmt.Errorf("stream push flush failed: %w", err))
return return
} }
resetTimer() resetTimer()
} }
case <-timer.C: case <-timer.C:
if err := flushWithRetry(); err != nil { if err := flushWithRetry(); err != nil {
_ = c.Close() _ = c.closeWithError(fmt.Errorf("stream push flush failed: %w", err))
return return
} }
resetTimer() resetTimer()
@@ -858,7 +983,7 @@ func (c *streamSplitConn) pushLoop() {
} }
if buf.Len()+len(b) > maxBatchBytes { if buf.Len()+len(b) > maxBatchBytes {
if err := flushWithRetry(); err != nil { if err := flushWithRetry(); err != nil {
_ = c.Close() _ = c.closeWithError(fmt.Errorf("stream push flush failed: %w", err))
return return
} }
} }
@@ -905,6 +1030,43 @@ func isDialError(err error) bool {
return false return false
} }
func isRetryableRequestError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return true
}
// net/http may return this when reusing a keep-alive conn that the peer already closed.
// Treat it as retryable: callers already implement bounded backoff retries.
if strings.Contains(strings.ToLower(err.Error()), "server closed idle connection") {
return true
}
// Unwrap common wrappers.
var urlErr *url.Error
if errors.As(err, &urlErr) {
return isRetryableRequestError(urlErr.Err)
}
// Connection-level transient failures.
if errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.ECONNRESET) {
return true
}
if errors.Is(err, io.ErrClosedPipe) || errors.Is(err, net.ErrClosed) {
return true
}
var netErr net.Error
if errors.As(err, &netErr) {
return netErr.Timeout() || netErr.Temporary()
}
return false
}
func (c *pollConn) closeWithError(err error) error { func (c *pollConn) closeWithError(err error) error {
_ = c.queuedConn.closeWithError(err) _ = c.queuedConn.closeWithError(err)
if c.cancel != nil { if c.cancel != nil {
@@ -1012,8 +1174,10 @@ func (c *pollConn) pullLoop() {
default: default:
} }
req, err := http.NewRequestWithContext(c.ctx, http.MethodGet, c.pullURL, nil) reqCtx, cancel := context.WithTimeout(c.ctx, 30*time.Second)
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, c.pullURL, nil)
if err != nil { if err != nil {
cancel()
_ = c.Close() _ = c.Close()
return return
} }
@@ -1023,8 +1187,10 @@ func (c *pollConn) pullLoop() {
resp, err := c.client.Do(req) resp, err := c.client.Do(req)
if err != nil { if err != nil {
if isDialError(err) && dialRetry < maxDialRetry { cancel()
if (isDialError(err) || isRetryableRequestError(err)) && dialRetry < maxDialRetry {
dialRetry++ dialRetry++
closeIdleConnections(c.client)
select { select {
case <-time.After(backoff): case <-time.After(backoff):
case <-c.closed: case <-c.closed:
@@ -1043,7 +1209,25 @@ func (c *pollConn) pullLoop() {
backoff = minBackoff backoff = minBackoff
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
if isRetryableStatusCode(resp.StatusCode) && dialRetry < maxDialRetry {
dialRetry++
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024))
_ = resp.Body.Close()
cancel()
closeIdleConnections(c.client)
select {
case <-time.After(backoff):
case <-c.closed:
return
}
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
continue
}
_ = resp.Body.Close() _ = resp.Body.Close()
cancel()
_ = c.closeWithError(fmt.Errorf("poll pull bad status: %s", resp.Status)) _ = c.closeWithError(fmt.Errorf("poll pull bad status: %s", resp.Status))
return return
} }
@@ -1068,7 +1252,12 @@ func (c *pollConn) pullLoop() {
} }
} }
_ = resp.Body.Close() _ = resp.Body.Close()
cancel()
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
// Treat transient stream breaks (RST/EOF) as an ended long-poll and retry.
if errors.Is(err, io.ErrUnexpectedEOF) || isRetryableRequestError(err) {
continue
}
_ = c.closeWithError(fmt.Errorf("poll pull scan failed: %w", err)) _ = c.closeWithError(fmt.Errorf("poll pull scan failed: %w", err))
return return
} }
@@ -1077,8 +1266,8 @@ func (c *pollConn) pullLoop() {
func (c *pollConn) pushLoop() { func (c *pollConn) pushLoop() {
const ( const (
maxBatchBytes = 64 * 1024 maxBatchBytes = 512 * 1024
flushInterval = 5 * time.Millisecond flushInterval = 50 * time.Millisecond
maxLineRawBytes = 16 * 1024 maxLineRawBytes = 16 * 1024
maxDialRetry = 12 maxDialRetry = 12
minBackoff = 10 * time.Millisecond minBackoff = 10 * time.Millisecond
@@ -1097,12 +1286,16 @@ func (c *pollConn) pushLoop() {
return nil return nil
} }
payload := buf.Bytes()
reqCtx, cancel := context.WithTimeout(c.ctx, 20*time.Second) reqCtx, cancel := context.WithTimeout(c.ctx, 20*time.Second)
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.pushURL, bytes.NewReader(buf.Bytes())) req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.pushURL, bytes.NewReader(payload))
if err != nil { if err != nil {
cancel() cancel()
return err return err
} }
req.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(payload)), nil
}
req.Host = c.headerHost req.Host = c.headerHost
applyTunnelHeaders(req.Header, c.headerHost, TunnelModePoll) applyTunnelHeaders(req.Header, c.headerHost, TunnelModePoll)
applyTunnelAuth(req, c.auth, TunnelModePoll, http.MethodPost, "/api/v1/upload") applyTunnelAuth(req, c.auth, TunnelModePoll, http.MethodPost, "/api/v1/upload")
@@ -1117,7 +1310,7 @@ func (c *pollConn) pushLoop() {
_ = resp.Body.Close() _ = resp.Body.Close()
cancel() cancel()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bad status: %s", resp.Status) return &httpStatusError{code: resp.StatusCode, status: resp.Status}
} }
buf.Reset() buf.Reset()
@@ -1131,8 +1324,22 @@ func (c *pollConn) pushLoop() {
for { for {
if err := flush(); err == nil { if err := flush(); err == nil {
return nil return nil
} else if isDialError(err) && dialRetry < maxDialRetry { } else if se := (*httpStatusError)(nil); errors.As(err, &se) && isRetryableStatusCode(se.code) && dialRetry < maxDialRetry {
dialRetry++ dialRetry++
closeIdleConnections(c.client)
select {
case <-time.After(backoff):
case <-c.closed:
return c.closedErr()
}
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
continue
} else if (isDialError(err) || isRetryableRequestError(err)) && dialRetry < maxDialRetry {
dialRetry++
closeIdleConnections(c.client)
select { select {
case <-time.After(backoff): case <-time.After(backoff):
case <-c.closed: case <-c.closed:
@@ -1482,6 +1689,16 @@ func (s *TunnelServer) HandleConn(rawConn net.Conn) (HandleResult, net.Conn, err
return HandleDone, nil, nil return HandleDone, nil, nil
} }
return s.handlePoll(rawConn, req, headerBytes, buffered) return s.handlePoll(rawConn, req, headerBytes, buffered)
case TunnelModeWS:
if s.mode != TunnelModeWS && s.mode != TunnelModeAuto {
if s.passThroughOnReject {
return reject()
}
_ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found")
_ = rawConn.Close()
return HandleDone, nil, nil
}
return s.handleWS(rawConn, req, headerBytes, buffered)
default: default:
if s.passThroughOnReject { if s.passThroughOnReject {
return reject() return reject()

View File

@@ -0,0 +1,176 @@
package httpmask
import (
"context"
"fmt"
"io"
mrand "math/rand"
"net"
stdhttp "net/http"
"net/url"
"strings"
"time"
"github.com/gobwas/ws"
"github.com/metacubex/tls"
)
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, serverName, err := normalizeWSDialTarget(serverAddress, opts.TLSEnabled, 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"),
}
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),
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" {
tlsConfig := &tls.Config{
ServerName: serverName,
MinVersion: tls.VersionTLS12,
}
d.TLSClient = func(conn net.Conn, hostname string) net.Conn {
return tls.Client(conn, tlsConfig)
}
}
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)
if opts.Upgrade == nil {
return wsConn, nil
}
upgraded, err := opts.Upgrade(wsConn)
if err != nil {
_ = wsConn.Close()
return nil, err
}
if upgraded != nil {
return upgraded, nil
}
return wsConn, nil
}

View File

@@ -0,0 +1,77 @@
package httpmask
import (
"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")
}
prefix := make([]byte, 0, len(headerBytes)+len(buffered))
prefix = append(prefix, headerBytes...)
prefix = append(prefix, buffered...)
wsConnRaw := newPreBufferedConn(rawConn, prefix)
if _, err := ws.Upgrade(wsConnRaw); err != nil {
_ = rawConn.Close()
return HandleDone, nil, nil
}
return HandleStartTunnel, newWSStreamConn(wsConnRaw, ws.StateServerSide), nil
}

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

View File

@@ -4,9 +4,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/binary" "encoding/binary"
"errors" "errors"
"log"
"math/rand" "math/rand"
"time"
) )
var ( var (
@@ -26,7 +24,7 @@ type Table struct {
func NewTable(key string, mode string) *Table { func NewTable(key string, mode string) *Table {
t, err := NewTableWithCustom(key, mode, "") t, err := NewTableWithCustom(key, mode, "")
if err != nil { if err != nil {
log.Panicf("failed to build table: %v", err) panic(err)
} }
return t return t
} }
@@ -35,8 +33,6 @@ func NewTable(key string, mode string) *Table {
// mode: "prefer_ascii" or "prefer_entropy". If a custom pattern is provided, ASCII mode still takes precedence. // mode: "prefer_ascii" or "prefer_entropy". If a custom pattern is provided, ASCII mode still takes precedence.
// The customPattern must contain 8 characters with exactly 2 x, 2 p, and 4 v (case-insensitive). // 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) { func NewTableWithCustom(key string, mode string, customPattern string) (*Table, error) {
start := time.Now()
layout, err := resolveLayout(mode, customPattern) layout, err := resolveLayout(mode, customPattern)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -126,7 +122,6 @@ func NewTableWithCustom(key string, mode string, customPattern string) (*Table,
} }
} }
} }
log.Printf("[Init] Sudoku Tables initialized (%s) in %v", layout.name, time.Since(start))
return t, nil return t, nil
} }

View 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{}

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

View File

@@ -4,7 +4,6 @@ import (
"bufio" "bufio"
"bytes" "bytes"
crand "crypto/rand" crand "crypto/rand"
"encoding/binary"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -56,26 +55,27 @@ func drainBuffered(r *bufio.Reader) ([]byte, error) {
func probeHandshakeBytes(probe []byte, cfg *ProtocolConfig, table *sudoku.Table) error { func probeHandshakeBytes(probe []byte, cfg *ProtocolConfig, table *sudoku.Table) error {
rc := &readOnlyConn{Reader: bytes.NewReader(probe)} rc := &readOnlyConn{Reader: bytes.NewReader(probe)}
_, obfsConn := buildServerObfsConn(rc, cfg, table, false) _, obfsConn := buildServerObfsConn(rc, cfg, table, false)
cConn, err := crypto.NewAEADConn(obfsConn, cfg.Key, cfg.AEADMethod) 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 { if err != nil {
return err return err
} }
var handshakeBuf [16]byte msg, err := ReadKIPMessage(cConn)
if _, err := io.ReadFull(cConn, handshakeBuf[:]); err != nil { if err != nil {
return err return err
} }
ts := int64(binary.BigEndian.Uint64(handshakeBuf[:8])) if msg.Type != KIPTypeClientHello {
if absInt64(time.Now().Unix()-ts) > 60 { return fmt.Errorf("unexpected handshake message: %d", msg.Type)
return fmt.Errorf("timestamp skew/replay detected")
} }
ch, err := DecodeKIPClientHelloPayload(msg.Payload)
modeBuf := []byte{0} if err != nil {
if _, err := io.ReadFull(cConn, modeBuf); err != nil {
return err return err
} }
if modeBuf[0] != downlinkMode(cfg) { if absInt64(time.Now().Unix()-ch.Timestamp.Unix()) > int64(kipHandshakeSkew.Seconds()) {
return fmt.Errorf("downlink mode mismatch") return fmt.Errorf("time skew/replay")
} }
return nil return nil
@@ -93,6 +93,17 @@ func selectTableByProbe(r *bufio.Reader, cfg *ProtocolConfig, tables []*sudoku.T
return nil, nil, fmt.Errorf("too many table candidates: %d", len(tables)) 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) probe, err := drainBuffered(r)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("drain buffered bytes failed: %w", err) return nil, nil, fmt.Errorf("drain buffered bytes failed: %w", err)
@@ -100,17 +111,18 @@ func selectTableByProbe(r *bufio.Reader, cfg *ProtocolConfig, tables []*sudoku.T
tmp := make([]byte, readChunk) tmp := make([]byte, readChunk)
for { for {
if len(tables) == 1 { if len(candidates) == 1 {
tail, err := drainBuffered(r) tail, err := drainBuffered(r)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("drain buffered bytes failed: %w", err) return nil, nil, fmt.Errorf("drain buffered bytes failed: %w", err)
} }
probe = append(probe, tail...) probe = append(probe, tail...)
return tables[0], probe, nil return candidates[0], probe, nil
} }
needMore := false needMore := false
for _, table := range tables { next := candidates[:0]
for _, table := range candidates {
err := probeHandshakeBytes(probe, cfg, table) err := probeHandshakeBytes(probe, cfg, table)
if err == nil { if err == nil {
tail, err := drainBuffered(r) tail, err := drainBuffered(r)
@@ -122,10 +134,13 @@ func selectTableByProbe(r *bufio.Reader, cfg *ProtocolConfig, tables []*sudoku.T
} }
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
needMore = true needMore = true
next = append(next, table)
} }
// Definitive mismatch: drop table.
} }
candidates = next
if !needMore { if len(candidates) == 0 || !needMore {
return nil, probe, fmt.Errorf("handshake table selection failed") return nil, probe, fmt.Errorf("handshake table selection failed")
} }
if len(probe) >= maxProbeBytes { if len(probe) >= maxProbeBytes {

View File

@@ -6,9 +6,7 @@ import (
"github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku" "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku"
) )
// NewTablesWithCustomPatterns builds one or more obfuscation tables from x/v/p custom patterns. func normalizeCustomPatterns(customTable string, customTables []string) []string {
// When customTables is non-empty it overrides customTable (matching upstream Sudoku behavior).
func NewTablesWithCustomPatterns(key string, tableType string, customTable string, customTables []string) ([]*sudoku.Table, error) {
patterns := customTables patterns := customTables
if len(patterns) == 0 && strings.TrimSpace(customTable) != "" { if len(patterns) == 0 && strings.TrimSpace(customTable) != "" {
patterns = []string{customTable} patterns = []string{customTable}
@@ -16,7 +14,15 @@ func NewTablesWithCustomPatterns(key string, tableType string, customTable strin
if len(patterns) == 0 { if len(patterns) == 0 {
patterns = []string{""} patterns = []string{""}
} }
return patterns
}
// 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 := normalizeCustomPatterns(customTable, customTables)
tables := make([]*sudoku.Table, 0, len(patterns)) tables := make([]*sudoku.Table, 0, len(patterns))
for _, pattern := range patterns { for _, pattern := range patterns {
pattern = strings.TrimSpace(pattern) pattern = strings.TrimSpace(pattern)
@@ -28,3 +34,17 @@ func NewTablesWithCustomPatterns(key string, tableType string, customTable strin
} }
return tables, nil 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 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 := normalizeCustomPatterns(customTable, customTables)
if len(patterns) > 0 && strings.TrimSpace(patterns[0]) != "" {
patterns = append([]string{""}, patterns...)
}
return NewTablesWithCustomPatterns(key, tableType, "", patterns)
}

View File

@@ -16,17 +16,9 @@ import (
) )
const ( const (
UoTMagicByte byte = 0xEE maxUoTPayload = 64 * 1024
uotVersion = 0x01
maxUoTPayload = 64 * 1024
) )
// WritePreface writes the UDP-over-TCP marker and version.
func WritePreface(w io.Writer) error {
_, err := w.Write([]byte{UoTMagicByte, uotVersion})
return err
}
// WriteDatagram sends a single UDP datagram frame over a reliable stream. // WriteDatagram sends a single UDP datagram frame over a reliable stream.
func WriteDatagram(w io.Writer, addr string, payload []byte) error { func WriteDatagram(w io.Writer, addr string, payload []byte) error {
addrBuf, err := EncodeAddress(addr) addrBuf, err := EncodeAddress(addr)
@@ -45,14 +37,13 @@ func WriteDatagram(w io.Writer, addr string, payload []byte) error {
binary.BigEndian.PutUint16(header[:2], uint16(len(addrBuf))) binary.BigEndian.PutUint16(header[:2], uint16(len(addrBuf)))
binary.BigEndian.PutUint16(header[2:], uint16(len(payload))) binary.BigEndian.PutUint16(header[2:], uint16(len(payload)))
if _, err := w.Write(header[:]); err != nil { if err := writeFull(w, header[:]); err != nil {
return err return err
} }
if _, err := w.Write(addrBuf); err != nil { if err := writeFull(w, addrBuf); err != nil {
return err return err
} }
_, err = w.Write(payload) return writeFull(w, payload)
return err
} }
// ReadDatagram parses a single UDP datagram frame from the reliable stream. // ReadDatagram parses a single UDP datagram frame from the reliable stream.

View File

@@ -0,0 +1,269 @@
package trusttunnel
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/netip"
"net/url"
"sync"
"time"
C "github.com/metacubex/mihomo/constant"
"github.com/metacubex/mihomo/transport/vmess"
"github.com/metacubex/http"
"github.com/metacubex/http/httptrace"
"github.com/metacubex/tls"
"golang.org/x/exp/slices"
)
type RoundTripper interface {
http.RoundTripper
CloseIdleConnections()
}
type ResolvUDPFunc func(ctx context.Context, server string) (netip.AddrPort, error)
type ClientOptions struct {
Dialer C.Dialer
ResolvUDP ResolvUDPFunc
Server string
Username string
Password string
TLSConfig *vmess.TLSConfig
QUIC bool
QUICCongestionControl string
QUICCwnd int
HealthCheck bool
}
type Client struct {
ctx context.Context
dialer C.Dialer
resolv ResolvUDPFunc
server string
auth string
roundTripper RoundTripper
startOnce sync.Once
healthCheck bool
healthCheckTimer *time.Timer
}
func NewClient(ctx context.Context, options ClientOptions) (client *Client, err error) {
client = &Client{
ctx: ctx,
dialer: options.Dialer,
resolv: options.ResolvUDP,
server: options.Server,
auth: buildAuth(options.Username, options.Password),
}
if options.QUIC {
if len(options.TLSConfig.NextProtos) == 0 {
options.TLSConfig.NextProtos = []string{"h3"}
} else if !slices.Contains(options.TLSConfig.NextProtos, "h3") {
return nil, errors.New("require alpn h3")
}
err = client.quicRoundTripper(options.TLSConfig, options.QUICCongestionControl, options.QUICCwnd)
if err != nil {
return nil, err
}
} else {
if len(options.TLSConfig.NextProtos) == 0 {
options.TLSConfig.NextProtos = []string{"h2"}
} else if !slices.Contains(options.TLSConfig.NextProtos, "h2") {
return nil, errors.New("require alpn h2")
}
client.h2RoundTripper(options.TLSConfig)
}
if options.HealthCheck {
client.healthCheck = true
}
return client, nil
}
func (c *Client) h2RoundTripper(tlsConfig *vmess.TLSConfig) {
c.roundTripper = &http.Http2Transport{
DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
conn, err := c.dialer.DialContext(ctx, network, c.server)
if err != nil {
return nil, err
}
tlsConn, err := vmess.StreamTLSConn(ctx, conn, tlsConfig)
if err != nil {
_ = conn.Close()
return nil, err
}
return tlsConn, nil
},
AllowHTTP: false,
IdleConnTimeout: DefaultSessionTimeout,
}
}
func (c *Client) start() {
if c.healthCheck {
c.healthCheckTimer = time.NewTimer(DefaultHealthCheckTimeout)
go c.loopHealthCheck()
}
}
func (c *Client) loopHealthCheck() {
for {
select {
case <-c.healthCheckTimer.C:
case <-c.ctx.Done():
c.healthCheckTimer.Stop()
return
}
ctx, cancel := context.WithTimeout(c.ctx, DefaultHealthCheckTimeout)
_ = c.HealthCheck(ctx)
cancel()
}
}
func (c *Client) resetHealthCheckTimer() {
if c.healthCheckTimer == nil {
return
}
c.healthCheckTimer.Reset(DefaultHealthCheckTimeout)
}
func (c *Client) dial(ctx context.Context, request *http.Request, conn *httpConn, pipeReader *io.PipeReader, pipeWriter *io.PipeWriter) {
c.startOnce.Do(c.start)
trace := &httptrace.ClientTrace{
GotConn: func(connInfo httptrace.GotConnInfo) {
conn.SetLocalAddr(connInfo.Conn.LocalAddr())
conn.SetRemoteAddr(connInfo.Conn.RemoteAddr())
},
}
request = request.WithContext(httptrace.WithClientTrace(ctx, trace))
response, err := c.roundTripper.RoundTrip(request)
if err != nil {
_ = pipeWriter.CloseWithError(err)
_ = pipeReader.CloseWithError(err)
conn.setUp(nil, err)
} else if response.StatusCode != http.StatusOK {
_ = response.Body.Close()
err = fmt.Errorf("unexpected status code: %d", response.StatusCode)
_ = pipeWriter.CloseWithError(err)
_ = pipeReader.CloseWithError(err)
conn.setUp(nil, err)
} else {
c.resetHealthCheckTimer()
conn.setUp(response.Body, nil)
}
}
func (c *Client) Dial(ctx context.Context, host string) (net.Conn, error) {
pipeReader, pipeWriter := io.Pipe()
request := &http.Request{
Method: http.MethodConnect,
URL: &url.URL{
Scheme: "https",
Host: host,
},
Header: make(http.Header),
Body: pipeReader,
Host: host,
}
request.Header.Add("User-Agent", TCPUserAgent)
request.Header.Add("Proxy-Authorization", c.auth)
conn := &tcpConn{
httpConn: httpConn{
writer: pipeWriter,
created: make(chan struct{}),
},
}
go c.dial(ctx, request, &conn.httpConn, pipeReader, pipeWriter)
return conn, nil
}
func (c *Client) ListenPacket(ctx context.Context) (net.PacketConn, error) {
pipeReader, pipeWriter := io.Pipe()
request := &http.Request{
Method: http.MethodConnect,
URL: &url.URL{
Scheme: "https",
Host: UDPMagicAddress,
},
Header: make(http.Header),
Body: pipeReader,
Host: UDPMagicAddress,
}
request.Header.Add("User-Agent", UDPUserAgent)
request.Header.Add("Proxy-Authorization", c.auth)
conn := &clientPacketConn{
packetConn: packetConn{
httpConn: httpConn{
writer: pipeWriter,
created: make(chan struct{}),
},
},
}
go c.dial(ctx, request, &conn.httpConn, pipeReader, pipeWriter)
return conn, nil
}
func (c *Client) ListenICMP(ctx context.Context) (*IcmpConn, error) {
pipeReader, pipeWriter := io.Pipe()
request := &http.Request{
Method: http.MethodConnect,
URL: &url.URL{
Scheme: "https",
Host: ICMPMagicAddress,
},
Header: make(http.Header),
Body: pipeReader,
Host: ICMPMagicAddress,
}
request.Header.Add("User-Agent", ICMPUserAgent)
request.Header.Add("Proxy-Authorization", c.auth)
conn := &IcmpConn{
httpConn{
writer: pipeWriter,
created: make(chan struct{}),
},
}
go c.dial(ctx, request, &conn.httpConn, pipeReader, pipeWriter)
return conn, nil
}
func (c *Client) Close() error {
forceCloseAllConnections(c.roundTripper)
if c.healthCheckTimer != nil {
c.healthCheckTimer.Stop()
}
return nil
}
func (c *Client) ResetConnections() {
forceCloseAllConnections(c.roundTripper)
c.resetHealthCheckTimer()
}
func (c *Client) HealthCheck(ctx context.Context) error {
defer c.resetHealthCheckTimer()
request := &http.Request{
Method: http.MethodConnect,
URL: &url.URL{
Scheme: "https",
Host: HealthCheckMagicAddress,
},
Header: make(http.Header),
Host: HealthCheckMagicAddress,
}
request.Header.Add("User-Agent", HealthCheckUserAgent)
request.Header.Add("Proxy-Authorization", c.auth)
response, err := c.roundTripper.RoundTrip(request.WithContext(ctx))
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", response.StatusCode)
}
return nil
}

View File

@@ -0,0 +1,4 @@
// Package trusttunnel copy and modify from:
// https://github.com/xchacha20-poly1305/sing-trusttunnel/tree/v0.1.1
// adopt for mihomo
package trusttunnel

View File

@@ -0,0 +1,18 @@
package trusttunnel
import (
"github.com/metacubex/mihomo/transport/gun"
"github.com/metacubex/http"
"github.com/metacubex/quic-go/http3"
)
func forceCloseAllConnections(roundTripper RoundTripper) {
roundTripper.CloseIdleConnections()
switch tr := roundTripper.(type) {
case *http.Http2Transport:
gun.CloseTransport(tr)
case *http3.Transport:
_ = tr.Close()
}
}

View File

@@ -0,0 +1,82 @@
package trusttunnel
import (
"encoding/binary"
"net/netip"
"github.com/metacubex/mihomo/common/buf"
)
type IcmpConn struct {
httpConn
}
func (i *IcmpConn) WritePing(id uint16, destination netip.Addr, sequenceNumber uint16, ttl uint8, size uint16) error {
request := buf.NewSize(2 + 16 + 2 + 1 + 2)
defer request.Release()
buf.Must(binary.Write(request, binary.BigEndian, id))
destinationAddress := buildPaddingIP(destination)
buf.Must1(request.Write(destinationAddress[:]))
buf.Must(binary.Write(request, binary.BigEndian, sequenceNumber))
buf.Must(binary.Write(request, binary.BigEndian, ttl))
buf.Must(binary.Write(request, binary.BigEndian, size))
return buf.Error(i.writeFlush(request.Bytes()))
}
func (i *IcmpConn) ReadPing() (id uint16, sourceAddress netip.Addr, icmpType uint8, code uint8, sequenceNumber uint16, err error) {
err = i.waitCreated()
if err != nil {
return
}
response := buf.NewSize(2 + 16 + 1 + 1 + 2)
defer response.Release()
_, err = response.ReadFullFrom(i.body, response.Cap())
if err != nil {
return
}
buf.Must(binary.Read(response, binary.BigEndian, &id))
var sourceAddressBuffer [16]byte
buf.Must1(response.Read(sourceAddressBuffer[:]))
sourceAddress = parse16BytesIP(sourceAddressBuffer)
buf.Must(binary.Read(response, binary.BigEndian, &icmpType))
buf.Must(binary.Read(response, binary.BigEndian, &code))
buf.Must(binary.Read(response, binary.BigEndian, &sequenceNumber))
return
}
func (i *IcmpConn) Close() error {
return i.httpConn.Close()
}
func (i *IcmpConn) ReadPingRequest() (id uint16, destination netip.Addr, sequenceNumber uint16, ttl uint8, size uint16, err error) {
err = i.waitCreated()
if err != nil {
return
}
request := buf.NewSize(2 + 16 + 2 + 1 + 2)
defer request.Release()
_, err = request.ReadFullFrom(i.body, request.Cap())
if err != nil {
return
}
buf.Must(binary.Read(request, binary.BigEndian, &id))
var destinationAddressBuffer [16]byte
buf.Must1(request.Read(destinationAddressBuffer[:]))
destination = parse16BytesIP(destinationAddressBuffer)
buf.Must(binary.Read(request, binary.BigEndian, &sequenceNumber))
buf.Must(binary.Read(request, binary.BigEndian, &ttl))
buf.Must(binary.Read(request, binary.BigEndian, &size))
return
}
func (i *IcmpConn) WritePingResponse(id uint16, sourceAddress netip.Addr, icmpType uint8, code uint8, sequenceNumber uint16) error {
response := buf.NewSize(2 + 16 + 1 + 1 + 2)
defer response.Release()
buf.Must(binary.Write(response, binary.BigEndian, id))
sourceAddressBytes := buildPaddingIP(sourceAddress)
buf.Must1(response.Write(sourceAddressBytes[:]))
buf.Must(binary.Write(response, binary.BigEndian, icmpType))
buf.Must(binary.Write(response, binary.BigEndian, code))
buf.Must(binary.Write(response, binary.BigEndian, sequenceNumber))
return buf.Error(i.writeFlush(response.Bytes()))
}

View File

@@ -0,0 +1,280 @@
package trusttunnel
import (
"encoding/binary"
"math"
"net"
"github.com/metacubex/sing/common"
"github.com/metacubex/sing/common/buf"
E "github.com/metacubex/sing/common/exceptions"
M "github.com/metacubex/sing/common/metadata"
N "github.com/metacubex/sing/common/network"
"github.com/metacubex/sing/common/rw"
)
type packetConn struct {
httpConn
readWaitOptions N.ReadWaitOptions
}
func (c *packetConn) InitializeReadWaiter(options N.ReadWaitOptions) (needCopy bool) {
c.readWaitOptions = options
return false
}
var (
_ N.NetPacketConn = (*clientPacketConn)(nil)
_ N.FrontHeadroom = (*clientPacketConn)(nil)
_ N.PacketReadWaiter = (*clientPacketConn)(nil)
)
type clientPacketConn struct {
packetConn
}
func (u *clientPacketConn) FrontHeadroom() int {
return 4 + 16 + 2 + 16 + 2 + 1 + math.MaxUint8
}
func (u *clientPacketConn) WaitReadPacket() (buffer *buf.Buffer, destination M.Socksaddr, err error) {
buffer = u.readWaitOptions.NewPacketBuffer()
destination, err = u.ReadPacket(buffer)
if err != nil {
buffer.Release()
return nil, M.Socksaddr{}, err
}
u.readWaitOptions.PostReturn(buffer)
return buffer, destination, nil
}
func (u *clientPacketConn) ReadPacket(buffer *buf.Buffer) (destination M.Socksaddr, err error) {
err = u.waitCreated()
if err != nil {
return M.Socksaddr{}, err
}
return u.readPacketFromServer(buffer)
}
func (u *clientPacketConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) {
buffer := buf.With(p)
destination, err := u.ReadPacket(buffer)
if err != nil {
return 0, nil, err
}
return buffer.Len(), destination.UDPAddr(), nil
}
func (u *clientPacketConn) WritePacket(buffer *buf.Buffer, destination M.Socksaddr) error {
return u.writePacketToServer(buffer, destination)
}
func (u *clientPacketConn) WriteTo(p []byte, addr net.Addr) (n int, err error) {
err = u.WritePacket(buf.As(p), M.SocksaddrFromNet(addr))
if err != nil {
return 0, err
}
return len(p), nil
}
func (u *clientPacketConn) readPacketFromServer(buffer *buf.Buffer) (destination M.Socksaddr, err error) {
header := buf.NewSize(4 + 16 + 2 + 16 + 2)
defer header.Release()
_, err = header.ReadFullFrom(u.body, header.Cap())
if err != nil {
return
}
var length uint32
common.Must(binary.Read(header, binary.BigEndian, &length))
var sourceAddressBuffer [16]byte
common.Must1(header.Read(sourceAddressBuffer[:]))
destination.Addr = parse16BytesIP(sourceAddressBuffer)
common.Must(binary.Read(header, binary.BigEndian, &destination.Port))
common.Must(rw.SkipN(header, 16+2)) // To local address:port
payloadLen := int(length) - (16 + 2 + 16 + 2)
if payloadLen < 0 {
return M.Socksaddr{}, E.New("invalid udp length: ", length)
}
_, err = buffer.ReadFullFrom(u.body, payloadLen)
return
}
func (u *clientPacketConn) writePacketToServer(buffer *buf.Buffer, source M.Socksaddr) error {
defer buffer.Release()
if !source.IsIP() {
return E.New("only support IP")
}
appName := AppName
if len(appName) > math.MaxUint8 {
appName = appName[:math.MaxUint8]
}
payloadLen := buffer.Len()
headerLen := 4 + 16 + 2 + 16 + 2 + 1 + len(appName)
lengthField := uint32(16 + 2 + 16 + 2 + 1 + len(appName) + payloadLen)
destinationAddress := buildPaddingIP(source.Addr)
var (
header *buf.Buffer
headerInBuffer bool
)
if buffer.Start() >= headerLen {
headerBytes := buffer.ExtendHeader(headerLen)
header = buf.With(headerBytes)
headerInBuffer = true
} else {
header = buf.NewSize(headerLen)
defer header.Release()
}
common.Must(binary.Write(header, binary.BigEndian, lengthField))
common.Must(header.WriteZeroN(16 + 2)) // Source address:port (unknown)
common.Must1(header.Write(destinationAddress[:]))
common.Must(binary.Write(header, binary.BigEndian, source.Port))
common.Must(binary.Write(header, binary.BigEndian, uint8(len(appName))))
common.Must1(header.WriteString(appName))
if !headerInBuffer {
_, err := u.writer.Write(header.Bytes())
if err != nil {
return err
}
}
_, err := u.writer.Write(buffer.Bytes())
if err != nil {
return err
}
if u.flusher != nil {
u.flusher.Flush()
}
return nil
}
var (
_ N.NetPacketConn = (*serverPacketConn)(nil)
_ N.FrontHeadroom = (*serverPacketConn)(nil)
_ N.PacketReadWaiter = (*serverPacketConn)(nil)
)
type serverPacketConn struct {
packetConn
}
func (u *serverPacketConn) FrontHeadroom() int {
return 4 + 16 + 2 + 16 + 2
}
func (u *serverPacketConn) WaitReadPacket() (buffer *buf.Buffer, destination M.Socksaddr, err error) {
buffer = u.readWaitOptions.NewPacketBuffer()
destination, err = u.ReadPacket(buffer)
if err != nil {
buffer.Release()
return nil, M.Socksaddr{}, err
}
u.readWaitOptions.PostReturn(buffer)
return buffer, destination, nil
}
func (u *serverPacketConn) ReadPacket(buffer *buf.Buffer) (destination M.Socksaddr, err error) {
err = u.waitCreated()
if err != nil {
return M.Socksaddr{}, err
}
return u.readPacketFromClient(buffer)
}
func (u *serverPacketConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) {
buffer := buf.With(p)
destination, err := u.ReadPacket(buffer)
if err != nil {
return 0, nil, err
}
return buffer.Len(), destination.UDPAddr(), nil
}
func (u *serverPacketConn) WritePacket(buffer *buf.Buffer, destination M.Socksaddr) error {
return u.writePacketToClient(buffer, destination)
}
func (u *serverPacketConn) WriteTo(p []byte, addr net.Addr) (n int, err error) {
err = u.WritePacket(buf.As(p), M.SocksaddrFromNet(addr))
if err != nil {
return 0, err
}
return len(p), nil
}
func (u *serverPacketConn) readPacketFromClient(buffer *buf.Buffer) (destination M.Socksaddr, err error) {
header := buf.NewSize(4 + 16 + 2 + 16 + 2 + 1)
defer header.Release()
_, err = header.ReadFullFrom(u.body, header.Cap())
if err != nil {
return
}
var length uint32
common.Must(binary.Read(header, binary.BigEndian, &length))
var sourceAddressBuffer [16]byte
common.Must1(header.Read(sourceAddressBuffer[:]))
var sourcePort uint16
common.Must(binary.Read(header, binary.BigEndian, &sourcePort))
_ = sourcePort
var destinationAddressBuffer [16]byte
common.Must1(header.Read(destinationAddressBuffer[:]))
destination.Addr = parse16BytesIP(destinationAddressBuffer)
common.Must(binary.Read(header, binary.BigEndian, &destination.Port))
var appNameLen uint8
common.Must(binary.Read(header, binary.BigEndian, &appNameLen))
if appNameLen > 0 {
err = rw.SkipN(u.body, int(appNameLen))
if err != nil {
return M.Socksaddr{}, err
}
}
payloadLen := int(length) - (16 + 2 + 16 + 2 + 1 + int(appNameLen))
if payloadLen < 0 {
return M.Socksaddr{}, E.New("invalid udp length: ", length)
}
_, err = buffer.ReadFullFrom(u.body, payloadLen)
return
}
func (u *serverPacketConn) writePacketToClient(buffer *buf.Buffer, source M.Socksaddr) error {
defer buffer.Release()
if !source.IsIP() {
return E.New("only support IP")
}
payloadLen := buffer.Len()
headerLen := 4 + 16 + 2 + 16 + 2
lengthField := uint32(16 + 2 + 16 + 2 + payloadLen)
sourceAddress := buildPaddingIP(source.Addr)
var destinationAddress [16]byte
var destinationPort uint16
var (
header *buf.Buffer
headerInBuffer bool
)
if buffer.Start() >= headerLen {
headerBytes := buffer.ExtendHeader(headerLen)
header = buf.With(headerBytes)
headerInBuffer = true
} else {
header = buf.NewSize(headerLen)
defer header.Release()
}
common.Must(binary.Write(header, binary.BigEndian, lengthField))
common.Must1(header.Write(sourceAddress[:]))
common.Must(binary.Write(header, binary.BigEndian, source.Port))
common.Must1(header.Write(destinationAddress[:]))
common.Must(binary.Write(header, binary.BigEndian, destinationPort))
if !headerInBuffer {
_, err := u.writer.Write(header.Bytes())
if err != nil {
return err
}
}
_, err := u.writer.Write(buffer.Bytes())
if err != nil {
return err
}
if u.flusher != nil {
u.flusher.Flush()
}
return nil
}

View File

@@ -0,0 +1,178 @@
package trusttunnel
import (
"bytes"
"encoding/base64"
"errors"
"io"
"net"
"net/http"
"net/netip"
"runtime"
"strings"
"time"
C "github.com/metacubex/mihomo/constant"
"github.com/metacubex/mihomo/transport/gun"
)
const (
UDPMagicAddress = "_udp2"
ICMPMagicAddress = "_icmp"
HealthCheckMagicAddress = "_check"
DefaultQuicStreamReceiveWindow = 131072 // Chrome's default
DefaultConnectionTimeout = 30 * time.Second
DefaultHealthCheckTimeout = 7 * time.Second
DefaultQuicMaxIdleTimeout = 2 * (DefaultConnectionTimeout + DefaultHealthCheckTimeout)
DefaultSessionTimeout = 30 * time.Second
)
var (
AppName = C.Name
Version = C.Version
// TCPUserAgent is user-agent for TCP connections.
// Format: <platform> <app_name>
TCPUserAgent = runtime.GOOS + " " + AppName + "/" + Version
// UDPUserAgent is user-agent for UDP multiplexinh.
// Format: <platform> _udp2
UDPUserAgent = runtime.GOOS + " " + UDPMagicAddress
// ICMPUserAgent is user-agent for ICMP multiplexinh.
// Format: <platform> _icmp
ICMPUserAgent = runtime.GOOS + " " + ICMPMagicAddress
HealthCheckUserAgent = runtime.GOOS
)
func buildAuth(username string, password string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
}
// parseBasicAuth parses an HTTP Basic Authentication strinh.
// "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==" returns ("Aladdin", "open sesame", true).
func parseBasicAuth(auth string) (username, password string, ok bool) {
const prefix = "Basic "
// Case insensitive prefix match. See Issue 22736.
if len(auth) < len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) {
return "", "", false
}
c, err := base64.StdEncoding.DecodeString(auth[len(prefix):])
if err != nil {
return "", "", false
}
cs := string(c)
username, password, ok = strings.Cut(cs, ":")
if !ok {
return "", "", false
}
return username, password, true
}
func parse16BytesIP(buffer [16]byte) netip.Addr {
var zeroPrefix [12]byte
isIPv4 := bytes.HasPrefix(buffer[:], zeroPrefix[:])
// Special: check ::1
isIPv4 = isIPv4 && !(buffer[12] == 0 && buffer[13] == 0 && buffer[14] == 0 && buffer[15] == 1)
if isIPv4 {
return netip.AddrFrom4([4]byte(buffer[12:16]))
}
return netip.AddrFrom16(buffer)
}
func buildPaddingIP(addr netip.Addr) (buffer [16]byte) {
if addr.Is6() {
return addr.As16()
}
ipv4 := addr.As4()
copy(buffer[12:16], ipv4[:])
return buffer
}
type httpConn struct {
writer io.Writer
flusher http.Flusher
body io.ReadCloser
created chan struct{}
createErr error
gun.NetAddr
// deadlines
deadline *time.Timer
}
func (h *httpConn) setUp(body io.ReadCloser, err error) {
h.body = body
h.createErr = err
close(h.created)
}
func (h *httpConn) waitCreated() error {
if h.body != nil || h.createErr != nil {
return h.createErr
}
<-h.created
return h.createErr
}
func (h *httpConn) Close() error {
var errorArr []error
if closer, ok := h.writer.(io.Closer); ok {
errorArr = append(errorArr, closer.Close())
}
if h.body != nil {
errorArr = append(errorArr, h.body.Close())
}
return errors.Join(errorArr...)
}
func (h *httpConn) writeFlush(p []byte) (n int, err error) {
n, err = h.writer.Write(p)
if h.flusher != nil {
h.flusher.Flush()
}
return n, err
}
func (h *httpConn) SetReadDeadline(t time.Time) error { return h.SetDeadline(t) }
func (h *httpConn) SetWriteDeadline(t time.Time) error { return h.SetDeadline(t) }
func (h *httpConn) SetDeadline(t time.Time) error {
if t.IsZero() {
if h.deadline != nil {
h.deadline.Stop()
h.deadline = nil
}
return nil
}
d := time.Until(t)
if h.deadline != nil {
h.deadline.Reset(d)
return nil
}
h.deadline = time.AfterFunc(d, func() {
h.Close()
})
return nil
}
var _ net.Conn = (*tcpConn)(nil)
type tcpConn struct {
httpConn
}
func (t *tcpConn) Read(b []byte) (n int, err error) {
err = t.waitCreated()
if err != nil {
return 0, err
}
n, err = t.body.Read(b)
return
}
func (t *tcpConn) Write(b []byte) (int, error) {
return t.writeFlush(b)
}

View File

@@ -0,0 +1,85 @@
package trusttunnel
import (
"context"
"errors"
"net"
"runtime"
"github.com/metacubex/mihomo/transport/tuic/common"
"github.com/metacubex/mihomo/transport/vmess"
"github.com/metacubex/http"
"github.com/metacubex/quic-go"
"github.com/metacubex/quic-go/http3"
"github.com/metacubex/tls"
)
func (c *Client) quicRoundTripper(tlsConfig *vmess.TLSConfig, congestionControlName string, cwnd int) error {
stdConfig, err := tlsConfig.ToStdConfig()
if err != nil {
return err
}
c.roundTripper = &http3.Transport{
TLSClientConfig: stdConfig,
QUICConfig: &quic.Config{
Versions: []quic.Version{quic.Version1},
MaxIdleTimeout: DefaultQuicMaxIdleTimeout,
InitialStreamReceiveWindow: DefaultQuicStreamReceiveWindow,
DisablePathMTUDiscovery: !(runtime.GOOS == "windows" || runtime.GOOS == "linux" || runtime.GOOS == "android" || runtime.GOOS == "darwin"),
Allow0RTT: false,
},
Dial: func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) {
addrPort, err := c.resolv(ctx, c.server)
if err != nil {
return nil, err
}
err = tlsConfig.ECH.ClientHandle(ctx, tlsCfg)
if err != nil {
return nil, err
}
packetConn, err := c.dialer.ListenPacket(ctx, "udp", "", addrPort)
if err != nil {
return nil, err
}
quicConn, err := quic.DialEarly(ctx, packetConn, net.UDPAddrFromAddrPort(addrPort), tlsCfg, cfg)
if err != nil {
_ = packetConn.Close()
return nil, err
}
common.SetCongestionController(quicConn, congestionControlName, cwnd)
return quicConn, nil
},
}
return nil
}
func (s *Service) configHTTP3Server(tlsConfig *tls.Config, udpConn net.PacketConn) error {
tlsConfig = http3.ConfigureTLSConfig(tlsConfig)
quicListener, err := quic.ListenEarly(udpConn, tlsConfig, &quic.Config{
Versions: []quic.Version{quic.Version1},
MaxIdleTimeout: DefaultQuicMaxIdleTimeout,
MaxIncomingStreams: 1 << 60,
Allow0RTT: true,
})
if err != nil {
return err
}
h3Server := &http3.Server{
Handler: s,
IdleTimeout: DefaultSessionTimeout,
ConnContext: func(ctx context.Context, conn *quic.Conn) context.Context {
common.SetCongestionController(conn, s.quicCongestionControl, s.quicCwnd)
return ctx
},
}
s.h3Server = h3Server
s.udpConn = udpConn
go func() {
sErr := h3Server.ServeListener(quicListener)
if sErr != nil && !errors.Is(sErr, http.ErrServerClosed) {
s.logger.ErrorContext(s.ctx, "HTTP3 server close: ", sErr)
}
}()
return nil
}

View File

@@ -0,0 +1,250 @@
package trusttunnel
import (
"context"
"errors"
"net"
"time"
"github.com/metacubex/http"
"github.com/metacubex/http/h2c"
"github.com/metacubex/quic-go/http3"
"github.com/metacubex/sing/common"
"github.com/metacubex/sing/common/auth"
"github.com/metacubex/sing/common/buf"
"github.com/metacubex/sing/common/bufio"
E "github.com/metacubex/sing/common/exceptions"
"github.com/metacubex/sing/common/logger"
M "github.com/metacubex/sing/common/metadata"
N "github.com/metacubex/sing/common/network"
"github.com/metacubex/tls"
)
type Handler interface {
N.TCPConnectionHandler
N.UDPConnectionHandler
}
type ICMPHandler interface {
NewICMPConnection(ctx context.Context, conn *IcmpConn)
}
type ServiceOptions struct {
Ctx context.Context
Logger logger.ContextLogger
Handler Handler
ICMPHandler ICMPHandler
QUICCongestionControl string
QUICCwnd int
}
type Service struct {
ctx context.Context
logger logger.ContextLogger
users map[string]string
handler Handler
icmpHandler ICMPHandler
quicCongestionControl string
quicCwnd int
httpServer *http.Server
h2Server *http.Http2Server
h3Server *http3.Server
tcpListener net.Listener
tlsListener net.Listener
udpConn net.PacketConn
}
func NewService(options ServiceOptions) *Service {
return &Service{
ctx: options.Ctx,
logger: options.Logger,
handler: options.Handler,
icmpHandler: options.ICMPHandler,
quicCongestionControl: options.QUICCongestionControl,
quicCwnd: options.QUICCwnd,
}
}
func (s *Service) Start(tcpListener net.Listener, udpConn net.PacketConn, tlsConfig *tls.Config) error {
if tcpListener != nil {
h2Server := &http.Http2Server{}
s.httpServer = &http.Server{
Handler: h2c.NewHandler(s, h2Server),
IdleTimeout: DefaultSessionTimeout,
BaseContext: func(net.Listener) context.Context {
return s.ctx
},
}
err := http.Http2ConfigureServer(s.httpServer, h2Server)
if err != nil {
return err
}
s.h2Server = h2Server
listener := tcpListener
s.tcpListener = tcpListener
if tlsConfig != nil {
listener = tls.NewListener(listener, tlsConfig)
s.tlsListener = listener
}
go func() {
sErr := s.httpServer.Serve(listener)
if sErr != nil && !errors.Is(sErr, http.ErrServerClosed) {
s.logger.ErrorContext(s.ctx, "HTTP server close: ", sErr)
}
}()
}
if udpConn != nil {
err := s.configHTTP3Server(tlsConfig, udpConn)
if err != nil {
return err
}
}
return nil
}
func (s *Service) UpdateUsers(users map[string]string) {
s.users = users
}
func (s *Service) Close() error {
var shutdownErr error
if s.httpServer != nil {
const shutdownTimeout = 5 * time.Second
ctx, cancel := context.WithTimeout(s.ctx, shutdownTimeout)
shutdownErr = s.httpServer.Shutdown(ctx)
cancel()
if errors.Is(shutdownErr, http.ErrServerClosed) {
shutdownErr = nil
}
}
closeErr := common.Close(
common.PtrOrNil(s.httpServer),
s.tlsListener,
s.tcpListener,
common.PtrOrNil(s.h3Server),
s.udpConn,
)
return E.Errors(shutdownErr, closeErr)
}
func (s *Service) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
authorization := request.Header.Get("Proxy-Authorization")
username, loaded := s.verify(authorization)
if !loaded {
writer.WriteHeader(http.StatusProxyAuthRequired)
s.badRequest(request.Context(), request, E.New("authorization failed"))
return
}
if request.Method != http.MethodConnect {
writer.WriteHeader(http.StatusMethodNotAllowed)
s.badRequest(request.Context(), request, E.New("unexpected HTTP method ", request.Method))
return
}
ctx := request.Context()
ctx = auth.ContextWithUser(ctx, username)
s.logger.DebugContext(ctx, "[", username, "] ", "request from ", request.RemoteAddr)
s.logger.DebugContext(ctx, "[", username, "] ", "request to ", request.Host)
switch request.Host {
case UDPMagicAddress:
writer.WriteHeader(http.StatusOK)
flusher, isFlusher := writer.(http.Flusher)
if isFlusher {
flusher.Flush()
}
conn := &serverPacketConn{
packetConn: packetConn{
httpConn: httpConn{
writer: writer,
flusher: flusher,
created: make(chan struct{}),
},
},
}
conn.SetAddrFromRequest(request)
conn.setUp(request.Body, nil)
firstPacket := buf.NewPacket()
destination, err := conn.ReadPacket(firstPacket)
if err != nil {
firstPacket.Release()
_ = conn.Close()
s.logger.ErrorContext(ctx, E.Cause(err, "read first packet of ", request.RemoteAddr))
return
}
destination = destination.Unwrap()
cachedConn := bufio.NewCachedPacketConn(conn, firstPacket, destination)
_ = s.handler.NewPacketConnection(ctx, cachedConn, M.Metadata{
Protocol: "trusttunnel",
Source: M.ParseSocksaddr(request.RemoteAddr),
Destination: destination,
})
case ICMPMagicAddress:
flusher, isFlusher := writer.(http.Flusher)
if s.icmpHandler == nil {
writer.WriteHeader(http.StatusNotImplemented)
if isFlusher {
flusher.Flush()
}
_ = request.Body.Close()
} else {
writer.WriteHeader(http.StatusOK)
if isFlusher {
flusher.Flush()
}
conn := &IcmpConn{
httpConn{
writer: writer,
flusher: flusher,
created: make(chan struct{}),
},
}
conn.SetAddrFromRequest(request)
conn.setUp(request.Body, nil)
s.icmpHandler.NewICMPConnection(ctx, conn)
}
case HealthCheckMagicAddress:
writer.WriteHeader(http.StatusOK)
if flusher, isFlusher := writer.(http.Flusher); isFlusher {
flusher.Flush()
}
_ = request.Body.Close()
default:
writer.WriteHeader(http.StatusOK)
flusher, isFlusher := writer.(http.Flusher)
if isFlusher {
flusher.Flush()
}
conn := &tcpConn{
httpConn{
writer: writer,
flusher: flusher,
created: make(chan struct{}),
},
}
conn.SetAddrFromRequest(request)
conn.setUp(request.Body, nil)
_ = s.handler.NewConnection(ctx, conn, M.Metadata{
Protocol: "trusttunnel",
Source: M.ParseSocksaddr(request.RemoteAddr),
Destination: M.ParseSocksaddr(request.Host).Unwrap(),
})
}
}
func (s *Service) verify(authorization string) (username string, loaded bool) {
username, password, loaded := parseBasicAuth(authorization)
if !loaded {
return "", false
}
recordedPassword, loaded := s.users[username]
if !loaded {
return "", false
}
if password != recordedPassword {
return "", false
}
return username, true
}
func (s *Service) badRequest(ctx context.Context, request *http.Request, err error) {
s.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", request.RemoteAddr))
}

View File

@@ -24,12 +24,8 @@ type TLSConfig struct {
Reality *tlsC.RealityConfig Reality *tlsC.RealityConfig
} }
type ECHConfig struct { func (cfg *TLSConfig) ToStdConfig() (*tls.Config, error) {
Enable bool return ca.GetTLSConfig(ca.Option{
}
func StreamTLSConn(ctx context.Context, conn net.Conn, cfg *TLSConfig) (net.Conn, error) {
tlsConfig, err := ca.GetTLSConfig(ca.Option{
TLSConfig: &tls.Config{ TLSConfig: &tls.Config{
ServerName: cfg.Host, ServerName: cfg.Host,
InsecureSkipVerify: cfg.SkipCertVerify, InsecureSkipVerify: cfg.SkipCertVerify,
@@ -39,6 +35,10 @@ func StreamTLSConn(ctx context.Context, conn net.Conn, cfg *TLSConfig) (net.Conn
Certificate: cfg.Certificate, Certificate: cfg.Certificate,
PrivateKey: cfg.PrivateKey, PrivateKey: cfg.PrivateKey,
}) })
}
func StreamTLSConn(ctx context.Context, conn net.Conn, cfg *TLSConfig) (net.Conn, error) {
tlsConfig, err := cfg.ToStdConfig()
if err != nil { if err != nil {
return nil, err return nil, err
} }