From 195a33379d82cfe2901b22b575bd646de20c5305 Mon Sep 17 00:00:00 2001 From: Shtorm <108103062+shtorm-7@users.noreply.github.com> Date: Thu, 4 Jun 2026 01:47:50 +0300 Subject: [PATCH] Add OpenVPN, TrustTunnel, Sudoku, inbound managers. Fixes --- .goreleaser.yaml | 75 +- Makefile | 3 +- README.md | 3 + adapter/provider/adapter.go | 40 +- cmd/internal/build_libbox/main.go | 2 +- common/byteformats/formats.go | 74 - common/byteformats/json.go | 218 -- common/byteformats/json_test.go | 114 - common/onclose/conn.go | 38 + common/tls/openvpn_client.go | 135 + constant/proxy.go | 9 + .../admin_panel-manager-node/manager.json | 2 + examples/amnezia/client.json | 3 +- examples/masque/client.json | 20 +- examples/openvpn/auth-user-pass.json | 46 + examples/openvpn/tls-auth.json | 51 + examples/openvpn/tls-crypt-v2.json | 49 + examples/openvpn/tls-crypt.json | 52 + examples/provider/local.json | 2 + examples/provider/remote.json | 2 + examples/sudoku/client.json | 17 + examples/sudoku/client_tls.json | 29 + examples/sudoku/server.json | 15 + examples/sudoku/server_advanced.json | 22 + examples/trusttunnel/client.json | 78 + examples/trusttunnel/server.json | 32 + examples/wireguard/client.json | 3 +- go.mod | 20 +- go.sum | 60 +- include/openvpn.go | 12 + include/openvpn_stub.go | 20 + include/registry.go | 6 + include/sudoku.go | 17 + include/sudoku_stub.go | 27 + include/trusttunnel.go | 17 + include/trusttunnel_stub.go | 27 + option/manager.go | 2 +- option/masque.go | 6 +- option/node.go | 14 +- option/node_manager_api.go | 7 +- option/openvpn.go | 40 + option/provider.go | 17 +- option/sudoku.go | 54 + option/trusttunnel.go | 38 + protocol/anytls/inbound.go | 6 + protocol/http/inbound.go | 4 + protocol/limiter/bandwidth/conn.go | 38 +- protocol/limiter/bandwidth/limiter.go | 11 +- protocol/limiter/bandwidth/strategy.go | 139 +- protocol/limiter/connection/lock.go | 18 +- protocol/limiter/connection/outbound.go | 35 +- protocol/limiter/connection/strategy.go | 77 +- protocol/limiter/traffic/strategy.go | 58 +- protocol/masque/outbound.go | 3 +- protocol/mixed/inbound.go | 4 + protocol/naive/inbound.go | 4 + protocol/openvpn/outbound.go | 158 ++ protocol/socks/inbound.go | 4 + protocol/sudoku/inbound.go | 178 ++ protocol/sudoku/outbound.go | 401 +++ protocol/trusttunnel/inbound.go | 190 ++ protocol/trusttunnel/outbound.go | 118 + provider/local/provider.go | 8 +- provider/remote/provider.go | 6 +- release/DEFAULT_BUILD_TAGS | 2 +- release/DEFAULT_BUILD_TAGS_OTHERS | 2 +- release/DEFAULT_BUILD_TAGS_WINDOWS | 2 +- service/admin_panel/web/src/api/types.ts | 6 + .../web/src/pages/BandwidthLimitersPage.tsx | 5 +- .../admin_panel/web/src/pages/UsersPage.tsx | 8 +- service/manager/constant/dto.go | 12 +- service/manager/constant/repository.go | 2 + .../manager/repository/postgresql/filter.go | 78 +- .../repository/postgresql/repository.go | 139 +- service/manager/repository/sqlite/filter.go | 78 +- .../manager/repository/sqlite/repository.go | 139 +- service/manager/service.go | 173 +- service/node/inbound/anytls.go | 89 + service/node/inbound/http.go | 89 + service/node/inbound/hysteria.go | 15 +- service/node/inbound/hysteria2.go | 15 +- service/node/inbound/mixed.go | 89 + service/node/inbound/mtproxy.go | 15 +- service/node/inbound/naive.go | 89 + service/node/inbound/socks.go | 89 + service/node/inbound/trojan.go | 15 +- service/node/inbound/trusttunnel.go | 89 + service/node/inbound/tuic.go | 15 +- service/node/inbound/vless.go | 15 +- service/node/inbound/vmess.go | 3 +- service/node/limiter/bandwidth.go | 23 + service/node/limiter/connection.go | 25 +- service/node/limiter/traffic.go | 2 +- service/node/service.go | 29 +- .../node_manager_api/manager/manager.pb.go | 2 +- .../manager/manager_grpc.pb.go | 4 +- service/node_manager_api/server/node.go | 2 +- service/node_manager_api/server/server.go | 14 + test/go.mod | 223 +- test/go.sum | 550 ++-- transport/masque/adapter.go | 82 - transport/masque/tunnel.go | 43 +- transport/openvpn/cipher.go | 227 ++ transport/openvpn/client.go | 280 ++ transport/openvpn/config.go | 175 ++ transport/openvpn/control.go | 475 ++++ transport/openvpn/data.go | 91 + transport/openvpn/device.go | 33 + transport/openvpn/device_stack.go | 309 +++ transport/openvpn/device_stack_stub.go | 13 + transport/openvpn/keymethod.go | 250 ++ transport/openvpn/mux.go | 92 + transport/openvpn/packet.go | 254 ++ transport/openvpn/push.go | 139 + transport/openvpn/tlsauth.go | 100 + transport/openvpn/tlscrypt.go | 128 + transport/openvpn/tunnel.go | 284 ++ transport/sudoku/address.go | 100 + transport/sudoku/config.go | 212 ++ transport/sudoku/crypto/aead.go | 150 + transport/sudoku/crypto/ed25519.go | 116 + transport/sudoku/crypto/record_conn.go | 452 +++ transport/sudoku/early_handshake.go | 343 +++ transport/sudoku/handshake.go | 511 ++++ transport/sudoku/handshake_kip.go | 67 + transport/sudoku/httpmask_tunnel.go | 155 ++ transport/sudoku/init.go | 101 + transport/sudoku/kip.go | 259 ++ transport/sudoku/multiplex.go | 130 + transport/sudoku/multiplex/session.go | 493 ++++ transport/sudoku/multiplex/write_chunks.go | 19 + transport/sudoku/obfs/httpmask/auth.go | 161 ++ .../sudoku/obfs/httpmask/early_handshake.go | 174 ++ transport/sudoku/obfs/httpmask/halfpipe.go | 229 ++ transport/sudoku/obfs/httpmask/masker.go | 252 ++ transport/sudoku/obfs/httpmask/pathroot.go | 52 + transport/sudoku/obfs/httpmask/tunnel.go | 2442 +++++++++++++++++ transport/sudoku/obfs/httpmask/tunnel_ws.go | 190 ++ .../sudoku/obfs/httpmask/tunnel_ws_server.go | 109 + .../sudoku/obfs/httpmask/ws_stream_conn.go | 78 + transport/sudoku/obfs/sudoku/ascii_mode.go | 93 + transport/sudoku/obfs/sudoku/conn.go | 193 ++ transport/sudoku/obfs/sudoku/encode.go | 36 + transport/sudoku/obfs/sudoku/grid.go | 46 + transport/sudoku/obfs/sudoku/layout.go | 255 ++ transport/sudoku/obfs/sudoku/packed.go | 359 +++ transport/sudoku/obfs/sudoku/padding_prob.go | 42 + transport/sudoku/obfs/sudoku/pending.go | 57 + transport/sudoku/obfs/sudoku/rand.go | 56 + transport/sudoku/obfs/sudoku/table.go | 214 ++ transport/sudoku/obfs/sudoku/table_set.go | 38 + transport/sudoku/replay.go | 74 + transport/sudoku/session_keys.go | 58 + transport/sudoku/table_probe.go | 164 ++ transport/sudoku/tables.go | 68 + transport/sudoku/uot.go | 165 ++ transport/sudoku/write_chunks.go | 19 + transport/trojan/service.go | 38 + transport/trusttunnel/client.go | 323 +++ transport/trusttunnel/icmp.go | 62 + transport/trusttunnel/packet.go | 210 ++ transport/trusttunnel/protocol.go | 174 ++ transport/trusttunnel/quic.go | 140 + transport/trusttunnel/service.go | 218 ++ 164 files changed, 16665 insertions(+), 1332 deletions(-) delete mode 100644 common/byteformats/formats.go delete mode 100644 common/byteformats/json.go delete mode 100644 common/byteformats/json_test.go create mode 100644 common/onclose/conn.go create mode 100644 common/tls/openvpn_client.go create mode 100644 examples/openvpn/auth-user-pass.json create mode 100644 examples/openvpn/tls-auth.json create mode 100644 examples/openvpn/tls-crypt-v2.json create mode 100644 examples/openvpn/tls-crypt.json create mode 100644 examples/sudoku/client.json create mode 100644 examples/sudoku/client_tls.json create mode 100644 examples/sudoku/server.json create mode 100644 examples/sudoku/server_advanced.json create mode 100644 examples/trusttunnel/client.json create mode 100644 examples/trusttunnel/server.json create mode 100644 include/openvpn.go create mode 100644 include/openvpn_stub.go create mode 100644 include/sudoku.go create mode 100644 include/sudoku_stub.go create mode 100644 include/trusttunnel.go create mode 100644 include/trusttunnel_stub.go create mode 100644 option/openvpn.go create mode 100644 option/sudoku.go create mode 100644 option/trusttunnel.go create mode 100644 protocol/openvpn/outbound.go create mode 100644 protocol/sudoku/inbound.go create mode 100644 protocol/sudoku/outbound.go create mode 100644 protocol/trusttunnel/inbound.go create mode 100644 protocol/trusttunnel/outbound.go create mode 100644 service/node/inbound/anytls.go create mode 100644 service/node/inbound/http.go create mode 100644 service/node/inbound/mixed.go create mode 100644 service/node/inbound/naive.go create mode 100644 service/node/inbound/socks.go create mode 100644 service/node/inbound/trusttunnel.go delete mode 100644 transport/masque/adapter.go create mode 100644 transport/openvpn/cipher.go create mode 100644 transport/openvpn/client.go create mode 100644 transport/openvpn/config.go create mode 100644 transport/openvpn/control.go create mode 100644 transport/openvpn/data.go create mode 100644 transport/openvpn/device.go create mode 100644 transport/openvpn/device_stack.go create mode 100644 transport/openvpn/device_stack_stub.go create mode 100644 transport/openvpn/keymethod.go create mode 100644 transport/openvpn/mux.go create mode 100644 transport/openvpn/packet.go create mode 100644 transport/openvpn/push.go create mode 100644 transport/openvpn/tlsauth.go create mode 100644 transport/openvpn/tlscrypt.go create mode 100644 transport/openvpn/tunnel.go create mode 100644 transport/sudoku/address.go create mode 100644 transport/sudoku/config.go create mode 100644 transport/sudoku/crypto/aead.go create mode 100644 transport/sudoku/crypto/ed25519.go create mode 100644 transport/sudoku/crypto/record_conn.go create mode 100644 transport/sudoku/early_handshake.go create mode 100644 transport/sudoku/handshake.go create mode 100644 transport/sudoku/handshake_kip.go create mode 100644 transport/sudoku/httpmask_tunnel.go create mode 100644 transport/sudoku/init.go create mode 100644 transport/sudoku/kip.go create mode 100644 transport/sudoku/multiplex.go create mode 100644 transport/sudoku/multiplex/session.go create mode 100644 transport/sudoku/multiplex/write_chunks.go create mode 100644 transport/sudoku/obfs/httpmask/auth.go create mode 100644 transport/sudoku/obfs/httpmask/early_handshake.go create mode 100644 transport/sudoku/obfs/httpmask/halfpipe.go create mode 100644 transport/sudoku/obfs/httpmask/masker.go create mode 100644 transport/sudoku/obfs/httpmask/pathroot.go create mode 100644 transport/sudoku/obfs/httpmask/tunnel.go create mode 100644 transport/sudoku/obfs/httpmask/tunnel_ws.go create mode 100644 transport/sudoku/obfs/httpmask/tunnel_ws_server.go create mode 100644 transport/sudoku/obfs/httpmask/ws_stream_conn.go create mode 100644 transport/sudoku/obfs/sudoku/ascii_mode.go create mode 100644 transport/sudoku/obfs/sudoku/conn.go create mode 100644 transport/sudoku/obfs/sudoku/encode.go create mode 100644 transport/sudoku/obfs/sudoku/grid.go create mode 100644 transport/sudoku/obfs/sudoku/layout.go create mode 100644 transport/sudoku/obfs/sudoku/packed.go create mode 100644 transport/sudoku/obfs/sudoku/padding_prob.go create mode 100644 transport/sudoku/obfs/sudoku/pending.go create mode 100644 transport/sudoku/obfs/sudoku/rand.go create mode 100644 transport/sudoku/obfs/sudoku/table.go create mode 100644 transport/sudoku/obfs/sudoku/table_set.go create mode 100644 transport/sudoku/replay.go create mode 100644 transport/sudoku/session_keys.go create mode 100644 transport/sudoku/table_probe.go create mode 100644 transport/sudoku/tables.go create mode 100644 transport/sudoku/uot.go create mode 100644 transport/sudoku/write_chunks.go create mode 100644 transport/trusttunnel/client.go create mode 100644 transport/trusttunnel/icmp.go create mode 100644 transport/trusttunnel/packet.go create mode 100644 transport/trusttunnel/protocol.go create mode 100644 transport/trusttunnel/quic.go create mode 100644 transport/trusttunnel/service.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index f11f5ea3..814c1845 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -11,6 +11,7 @@ builds: - -X github.com/sagernet/sing-box/constant.Version={{ .Version }} - -s - -buildid= + - -checklinkname=0 tags: - with_gvisor - with_quic @@ -22,8 +23,13 @@ builds: - with_tailscale - with_masque - with_mtproxy + - with_openvpn + - with_trusttunnel + - with_sudoku - with_manager - with_admin_panel + - badlinkname + - tfogo_checklinkname0 env: - CGO_ENABLED=0 - GOTOOLCHAIN=local @@ -54,6 +60,11 @@ builds: - with_tailscale - with_masque - with_mtproxy + - with_openvpn + - with_trusttunnel + - with_sudoku + - badlinkname + - tfogo_checklinkname0 targets: - linux_mips - linux_mips_softfloat @@ -107,9 +118,14 @@ builds: - with_tailscale - with_masque - with_mtproxy - - with_naive_outbound + - with_openvpn + - with_trusttunnel + - with_sudoku - with_manager - with_admin_panel + - badlinkname + - tfogo_checklinkname0 + - with_naive_outbound - with_purego env: - CGO_ENABLED=0 @@ -134,9 +150,14 @@ builds: - with_tailscale - with_masque - with_mtproxy - - with_naive_outbound + - with_openvpn + - with_trusttunnel + - with_sudoku - with_manager - with_admin_panel + - badlinkname + - tfogo_checklinkname0 + - with_naive_outbound - with_purego env: - CGO_ENABLED=0 @@ -161,9 +182,14 @@ builds: - with_tailscale - with_masque - with_mtproxy - - with_naive_outbound + - with_openvpn + - with_trusttunnel + - with_sudoku - with_manager - with_admin_panel + - badlinkname + - tfogo_checklinkname0 + - with_naive_outbound - with_purego env: - CGO_ENABLED=0 @@ -188,9 +214,14 @@ builds: - with_tailscale - with_masque - with_mtproxy - - with_naive_outbound + - with_openvpn + - with_trusttunnel + - with_sudoku - with_manager - with_admin_panel + - badlinkname + - tfogo_checklinkname0 + - with_naive_outbound - with_purego env: - CGO_ENABLED=0 @@ -215,8 +246,13 @@ builds: - with_tailscale - with_masque - with_mtproxy + - with_openvpn + - with_trusttunnel + - with_sudoku - with_manager - with_admin_panel + - badlinkname + - tfogo_checklinkname0 - with_naive_outbound env: - CGO_ENABLED=1 @@ -258,8 +294,13 @@ builds: - with_tailscale - with_masque - with_mtproxy + - with_openvpn + - with_trusttunnel + - with_sudoku - with_manager - with_admin_panel + - badlinkname + - tfogo_checklinkname0 - with_naive_outbound - with_musl env: @@ -309,6 +350,11 @@ builds: - with_tailscale - with_masque - with_mtproxy + - with_openvpn + - with_trusttunnel + - with_sudoku + - badlinkname + - tfogo_checklinkname0 targets: - linux_mips - linux_mips_softfloat @@ -372,16 +418,6 @@ archives: files: - LICENSE name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if and .Mips (not (eq .Mips "hardfloat")) }}-{{ .Mips }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' - - id: archive-naive-glibc - <<: *template - builds: - - naive-glibc - name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if and .Mips (not (eq .Mips "hardfloat")) }}-{{ .Mips }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}-glibc' - - id: archive-naive-musl - <<: *template - builds: - - naive-musl - name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if and .Mips (not (eq .Mips "hardfloat")) }}-{{ .Mips }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}-musl' - id: archive-naive-purego-linux-amd64 <<: *template builds: @@ -418,11 +454,16 @@ archives: - src: dist/naive-purego-windows-arm64_*/libcronet* strip_parent: true name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}-purego' - - id: archive-legacy + - id: archive-naive-glibc <<: *template builds: - - legacy - name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}-legacy' + - naive-glibc + name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if and .Mips (not (eq .Mips "hardfloat")) }}-{{ .Mips }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}-glibc' + - id: archive-naive-musl + <<: *template + builds: + - naive-musl + name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if and .Mips (not (eq .Mips "hardfloat")) }}-{{ .Mips }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}-musl' - id: archive-compressed <<: *template builds: diff --git a/Makefile b/Makefile index 8b242fde..9aa87900 100644 --- a/Makefile +++ b/Makefile @@ -123,12 +123,11 @@ build_android: upload_android: mkdir -p dist/release_android cp ../sing-box-for-android/app/build/outputs/apk/other/release/*.apk dist/release_android - cp ../sing-box-for-android/app/build/outputs/apk/otherLegacy/release/*.apk dist/release_android ghr --replace --draft --prerelease -p 5 "v${VERSION}" dist/release_android ./codeberg-release.sh --replace --draft --prerelease -p 5 "v${VERSION}" dist/release_android rm -rf dist/release_android -release_android: lib_android update_android_version build_android +release_android: lib_android update_android_version build_android upload_android publish_android: cd ../sing-box-for-android && ./gradlew :app:publishPlayReleaseBundle && ./gradlew --stop diff --git a/README.md b/README.md index 2cc711a6..f894cc50 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ Sing-box with extended features. - **MASQUE** — Cloudflare MASQUE proxy over QUIC / HTTP-2 - **MTProxy** — Telegram MTProxy server with FakeTLS and domain fronting - **Mieru** — Secure, hard to classify, hard to probe network protocol +- **OpenVPN** — OpenVPN client with tls-auth, tls-crypt, and tls-crypt-v2 support +- **TrustTunnel** — AdGuard's obfuscated VPN protocol, indistinguishable from HTTPS traffic +- **Sudoku** — Traffic obfuscation protocol based on 4×4 Sudoku puzzles with low-entropy fingerprints - **VPN** — Routed tunnel over any TCP sing-box protocol - **Bond** — Link aggregation for increasing throughput - **Fallback** — Outbound group with priority-based switching diff --git a/adapter/provider/adapter.go b/adapter/provider/adapter.go index 3c55783e..162506a8 100644 --- a/adapter/provider/adapter.go +++ b/adapter/provider/adapter.go @@ -3,6 +3,8 @@ package provider import ( "context" "reflect" + "regexp" + "strings" "sync" "sync/atomic" "time" @@ -34,10 +36,11 @@ type Adapter struct { callbackAccess sync.Mutex callbacks list.List[adapter.ProviderUpdateCallback] - link string - enabled bool - timeout time.Duration - interval time.Duration + link string + enabled bool + removeEmojis bool + timeout time.Duration + interval time.Duration } func NewAdapter(ctx context.Context, router adapter.Router, outbound adapter.OutboundManager, logFactory log.Factory, logger log.ContextLogger, providerTag string, providerType string, options option.ProviderHealthCheckOptions) Adapter { @@ -68,6 +71,10 @@ func NewAdapter(ctx context.Context, router adapter.Router, outbound adapter.Out } } +func (a *Adapter) SetRemoveEmojis(remove bool) { + a.removeEmojis = remove +} + func (a *Adapter) Start() error { a.history = service.FromContext[adapter.URLTestHistoryStorage](a.ctx) if a.history == nil { @@ -102,6 +109,10 @@ func (a *Adapter) Outbound(tag string) (adapter.Outbound, bool) { } func (a *Adapter) UpdateOutbounds(oldOpts []option.Outbound, newOpts []option.Outbound) { + if a.removeEmojis { + removeEmojisFromTags(newOpts) + } + uniquifyTags(newOpts) a.removeUseless(newOpts) var ( oldOptByTag = make(map[string]option.Outbound) @@ -265,3 +276,24 @@ func (a *Adapter) removeUseless(newOpts []option.Outbound) { } } } + +func uniquifyTags(opts []option.Outbound) { + count := make(map[string]int) + for i, opt := range opts { + count[opt.Tag]++ + if count[opt.Tag] > 1 { + opts[i].Tag = F.ToString(opt.Tag, " #", count[opt.Tag]) + } + } +} + +func removeEmojisFromTags(opts []option.Outbound) { + for i, opt := range opts { + cleaned := emojiRegex.ReplaceAllString(opt.Tag, "") + cleaned = multiSpaceRegex.ReplaceAllString(cleaned, " ") + opts[i].Tag = strings.TrimSpace(cleaned) + } +} + +var emojiRegex = regexp.MustCompile(`[\x{1F1E0}-\x{1F1FF}\x{1F300}-\x{1F9FF}\x{2600}-\x{27BF}\x{FE00}-\x{FE0F}\x{200D}]+`) +var multiSpaceRegex = regexp.MustCompile(`\s{2,}`) diff --git a/cmd/internal/build_libbox/main.go b/cmd/internal/build_libbox/main.go index c49232ee..e2659117 100644 --- a/cmd/internal/build_libbox/main.go +++ b/cmd/internal/build_libbox/main.go @@ -63,7 +63,7 @@ func init() { sharedFlags = append(sharedFlags, "-ldflags", "-X github.com/sagernet/sing-box/constant.Version="+currentTag+" -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0") debugFlags = append(debugFlags, "-ldflags", "-X github.com/sagernet/sing-box/constant.Version="+currentTag+" -X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0") - sharedTags = append(sharedTags, "with_gvisor", "with_quic", "with_wireguard", "with_masque", "with_mtproxy", "with_utls", "with_naive_outbound", "with_clash_api", "badlinkname", "tfogo_checklinkname0") + sharedTags = append(sharedTags, "with_gvisor", "with_quic", "with_wireguard", "with_masque", "with_mtproxy", "with_trusttunnel", "with_openvpn", "with_sudoku", "with_utls", "with_naive_outbound", "with_clash_api", "badlinkname", "tfogo_checklinkname0") darwinTags = append(darwinTags, "with_dhcp", "grpcnotrace") // memcTags = append(memcTags, "with_tailscale") sharedTags = append(sharedTags, "with_tailscale", "ts_omit_logtail", "ts_omit_ssh", "ts_omit_drive", "ts_omit_taildrop", "ts_omit_webclient", "ts_omit_doctor", "ts_omit_capture", "ts_omit_kube", "ts_omit_aws", "ts_omit_synology", "ts_omit_bird") diff --git a/common/byteformats/formats.go b/common/byteformats/formats.go deleted file mode 100644 index ed75f78a..00000000 --- a/common/byteformats/formats.go +++ /dev/null @@ -1,74 +0,0 @@ -package byteformats - -import ( - "fmt" - "math" -) - -var ( - unitNames = []string{"B", "kB", "MB", "GB", "TB", "PB", "EB"} - iUnitNames = []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"} - kUnitNames = []string{"kB", "MB", "GB", "TB", "PB", "EB"} - kiUnitNames = []string{"KiB", "MiB", "GiB", "TiB", "PiB", "EiB"} -) - -func formatBytes(s uint64, base float64, sizes []string) string { - if s < 10 { - return fmt.Sprintf("%d B", s) - } - e := math.Floor(logn(float64(s), base)) - suffix := sizes[int(e)] - val := math.Floor(float64(s)/math.Pow(base, e)*10+0.5) / 10 - f := "%.0f %s" - if val < 10 { - f = "%.1f %s" - } - - return fmt.Sprintf(f, val, suffix) -} - -func formatKBytes(s uint64, base float64, sizes []string) string { - if s == 0 { - return fmt.Sprintf("0 %s", sizes[0]) - } - e := math.Floor(logn(float64(s), base)) - if e < 1 { - e = 1 - } - suffix := sizes[int(e)-1] - val := math.Floor(float64(s)/math.Pow(base, e)*10+0.5) / 10 - f := "%.0f %s" - if val < 10 { - f = "%.1f %s" - } - - return fmt.Sprintf(f, val, suffix) -} - -func logn(n, b float64) float64 { - return math.Log(n) / math.Log(b) -} - -func FormatBytes(s uint64) string { - return formatBytes(s, 1000, unitNames) -} - -func FormatMemoryBytes(s uint64) string { - return formatBytes(s, 1024, unitNames) -} - -func FormatIBytes(s uint64) string { - return formatBytes(s, 1024, iUnitNames) -} - -func FormatKBytes(s uint64) string { - return formatKBytes(s, 1000, kUnitNames) -} - -func FormatMemoryKBytes(s uint64) string { - return formatKBytes(s, 1024, kUnitNames) -} - -func FormatKIBytes(s uint64) string { - return formatKBytes(s, 1024, kiUnitNames) -} diff --git a/common/byteformats/json.go b/common/byteformats/json.go deleted file mode 100644 index 41d90fc1..00000000 --- a/common/byteformats/json.go +++ /dev/null @@ -1,218 +0,0 @@ -package byteformats - -import ( - "encoding/json" - "fmt" - "strconv" - "strings" -) - -const ( - Byte = 1 << (iota * 10) - KiByte - MiByte - GiByte - TiByte - PiByte - EiByte -) - -const ( - KByte = Byte * 1000 - MByte = KByte * 1000 - GByte = MByte * 1000 - TByte = GByte * 1000 - PByte = TByte * 1000 - EByte = PByte * 1000 -) - -var unitValueTable = map[string]uint64{ - "b": Byte, - "k": KByte, - "kb": KByte, - "ki": KiByte, - "kib": KiByte, - "m": MByte, - "mb": MByte, - "mi": MiByte, - "mib": MiByte, - "g": GByte, - "gb": GByte, - "gi": GiByte, - "gib": GiByte, - "t": TByte, - "tb": TByte, - "ti": TiByte, - "tib": TiByte, - "p": PByte, - "pb": PByte, - "pi": PiByte, - "pib": PiByte, - "e": EByte, - "eb": EByte, - "ei": EiByte, - "eib": EiByte, -} - -var memoryUnitValueTable = map[string]uint64{ - "b": Byte, - "k": KiByte, - "kb": KiByte, - "m": MiByte, - "mb": MiByte, - "g": GiByte, - "gb": GiByte, - "t": TiByte, - "tb": TiByte, - "p": PiByte, - "pb": PiByte, - "e": EiByte, - "eb": EiByte, -} - -var networkUnitValueTable = map[string]uint64{ - "Bps": Byte, - "Kbps": KByte / 8, - "KBps": KByte, - "Mbps": MByte / 8, - "MBps": MByte, - "Gbps": GByte / 8, - "GBps": GByte, - "Tbps": TByte / 8, - "TBps": TByte, - "Pbps": PByte / 8, - "PBps": PByte, - "Ebps": EByte / 8, - "EBps": EByte, -} - -type rawBytes struct { - value uint64 - unit string - unitValue uint64 -} - -func (b rawBytes) MarshalJSON() ([]byte, error) { - if b.unit == "" { - return json.Marshal(b.value) - } - return json.Marshal(strconv.FormatUint(b.value/b.unitValue, 10) + b.unit) -} - -func parseUnit(b *rawBytes, unitTable map[string]uint64, caseSensitive bool, bytes []byte) error { - var intValue int64 - err := json.Unmarshal(bytes, &intValue) - if err == nil { - b.value = uint64(intValue) - b.unit = "" - b.unitValue = 1 - return nil - } - var stringValue string - err = json.Unmarshal(bytes, &stringValue) - if err != nil { - return err - } - if strings.TrimSpace(stringValue) == "" { - b.value = 0 - b.unit = "" - b.unitValue = 1 - return nil - } - unitIndex := 0 - for i, c := range stringValue { - if c < '0' || c > '9' { - unitIndex = i - break - } - } - if unitIndex == 0 { - return fmt.Errorf("invalid format: %s", stringValue) - } - value, err := strconv.ParseUint(stringValue[:unitIndex], 10, 64) - if err != nil { - return fmt.Errorf("parse %s: %w", stringValue[:unitIndex], err) - } - rawUnit := stringValue[unitIndex:] - var unit string - if caseSensitive { - unit = strings.TrimSpace(rawUnit) - } else { - unit = strings.TrimSpace(strings.ToLower(rawUnit)) - } - unitValue, loaded := unitTable[unit] - if !loaded { - return fmt.Errorf("unsupported unit: %s", rawUnit) - } - b.value = value * unitValue - b.unit = rawUnit - b.unitValue = unitValue - return nil -} - -type Bytes struct { - rawBytes -} - -func (b *Bytes) Value() uint64 { - if b == nil { - return 0 - } - return b.value -} - -func (b *Bytes) UnmarshalJSON(bytes []byte) error { - return parseUnit(&b.rawBytes, unitValueTable, false, bytes) -} - -type MemoryBytes struct { - rawBytes -} - -func (m *MemoryBytes) Value() uint64 { - if m == nil { - return 0 - } - return m.value -} - -func (m *MemoryBytes) UnmarshalJSON(bytes []byte) error { - return parseUnit(&m.rawBytes, memoryUnitValueTable, false, bytes) -} - -type NetworkBytes struct { - rawBytes -} - -func (n *NetworkBytes) Value() uint64 { - if n == nil { - return 0 - } - return n.value -} - -func (n *NetworkBytes) UnmarshalJSON(bytes []byte) error { - return parseUnit(&n.rawBytes, networkUnitValueTable, true, bytes) -} - -type NetworkBytesCompat struct { - rawBytes -} - -func (n *NetworkBytesCompat) Value() uint64 { - if n == nil { - return 0 - } - return n.value -} - -func (n *NetworkBytesCompat) UnmarshalJSON(bytes []byte) error { - err := parseUnit(&n.rawBytes, networkUnitValueTable, true, bytes) - if err != nil { - newErr := parseUnit(&n.rawBytes, unitValueTable, false, bytes) - if newErr == nil { - return nil - } - } - return err -} diff --git a/common/byteformats/json_test.go b/common/byteformats/json_test.go deleted file mode 100644 index edb214b0..00000000 --- a/common/byteformats/json_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package byteformats_test - -import ( - "encoding/json" - "testing" - - "github.com/sagernet/sing-box/common/byteformats" - - "github.com/stretchr/testify/require" -) - -func TestNetworkBytes(t *testing.T) { - t.Parallel() - testMap := map[string]uint64{ - "1 Bps": byteformats.Byte, - "1 Kbps": byteformats.KByte / 8, - "1 KBps": byteformats.KByte, - "1 Mbps": byteformats.MByte / 8, - "1 MBps": byteformats.MByte, - "1 Gbps": byteformats.GByte / 8, - "1 GBps": byteformats.GByte, - "1 Tbps": byteformats.TByte / 8, - "1 TBps": byteformats.TByte, - "1 Pbps": byteformats.PByte / 8, - "1 PBps": byteformats.PByte, - "1k": byteformats.KByte, - "1m": byteformats.MByte, - } - for k, v := range testMap { - var nb byteformats.NetworkBytesCompat - require.NoError(t, json.Unmarshal([]byte("\""+k+"\""), &nb)) - require.Equal(t, v, nb.Value()) - b, err := json.Marshal(nb) - require.NoError(t, err) - require.Equal(t, "\""+k+"\"", string(b)) - } -} - -func TestMemoryBytes(t *testing.T) { - t.Parallel() - testMap := map[string]uint64{ - "1 B": byteformats.Byte, - "1 KB": byteformats.KiByte, - "1 MB": byteformats.MiByte, - "1 GB": byteformats.GiByte, - "1 TB": byteformats.TiByte, - "1 PB": byteformats.PiByte, - } - for k, v := range testMap { - var mb byteformats.MemoryBytes - require.NoError(t, json.Unmarshal([]byte("\""+k+"\""), &mb)) - require.Equal(t, v, mb.Value()) - b, err := json.Marshal(mb) - require.NoError(t, err) - require.Equal(t, "\""+k+"\"", string(b)) - } -} - -func TestDefaultBytes(t *testing.T) { - t.Parallel() - testMap := map[string]uint64{ - "1 B": byteformats.Byte, - "1 KB": byteformats.KByte, - "1 KiB": byteformats.KiByte, - "1 MB": byteformats.MByte, - "1 MiB": byteformats.MiByte, - "1 GB": byteformats.GByte, - "1 GiB": byteformats.GiByte, - "1 TB": byteformats.TByte, - "1 TiB": byteformats.TiByte, - "1 PB": byteformats.PByte, - "1 PiB": byteformats.PiByte, - "1 EB": byteformats.EByte, - "1 EiB": byteformats.EiByte, - "1k": byteformats.KByte, - "1m": byteformats.MByte, - "1g": byteformats.GByte, - "1t": byteformats.TByte, - "1p": byteformats.PByte, - "1e": byteformats.EByte, - "1K": byteformats.KByte, - "1M": byteformats.MByte, - "1G": byteformats.GByte, - "1T": byteformats.TByte, - "1P": byteformats.PByte, - "1E": byteformats.EByte, - "1Ki": byteformats.KiByte, - "1Mi": byteformats.MiByte, - "1Gi": byteformats.GiByte, - "1Ti": byteformats.TiByte, - "1Pi": byteformats.PiByte, - "1Ei": byteformats.EiByte, - "1KiB": byteformats.KiByte, - "1MiB": byteformats.MiByte, - "1GiB": byteformats.GiByte, - "1TiB": byteformats.TiByte, - "1PiB": byteformats.PiByte, - "1EiB": byteformats.EiByte, - "1kB": byteformats.KByte, - "1mB": byteformats.MByte, - "1gB": byteformats.GByte, - "1tB": byteformats.TByte, - "1pB": byteformats.PByte, - "1eB": byteformats.EByte, - } - for k, v := range testMap { - var mb byteformats.Bytes - require.NoError(t, json.Unmarshal([]byte("\""+k+"\""), &mb)) - require.Equal(t, v, mb.Value()) - b, err := json.Marshal(mb) - require.NoError(t, err) - require.Equal(t, "\""+k+"\"", string(b)) - } -} diff --git a/common/onclose/conn.go b/common/onclose/conn.go new file mode 100644 index 00000000..6b7df158 --- /dev/null +++ b/common/onclose/conn.go @@ -0,0 +1,38 @@ +package onclose + +import ( + "net" + "sync" +) + +type CloseHandlerFunc = func() + +type Conn struct { + net.Conn + onClose func() + once sync.Once +} + +func NewConn(conn net.Conn, onClose func()) *Conn { + return &Conn{Conn: conn, onClose: onClose} +} + +func (c *Conn) Close() error { + c.once.Do(c.onClose) + return c.Conn.Close() +} + +type PacketConn struct { + net.PacketConn + onClose func() + once sync.Once +} + +func NewPacketConn(conn net.PacketConn, onClose func()) *PacketConn { + return &PacketConn{PacketConn: conn, onClose: onClose} +} + +func (c *PacketConn) Close() error { + c.once.Do(c.onClose) + return c.PacketConn.Close() +} diff --git a/common/tls/openvpn_client.go b/common/tls/openvpn_client.go new file mode 100644 index 00000000..d7832584 --- /dev/null +++ b/common/tls/openvpn_client.go @@ -0,0 +1,135 @@ +package tls + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "os" + "strings" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" +) + +func NewOpenVPNClient(ctx context.Context, logger logger.ContextLogger, options option.OpenVPNTLSOptions) (Config, error) { + ca := options.CA + if ca == "" && options.CAPath != "" { + data, err := os.ReadFile(options.CAPath) + if err != nil { + return nil, E.Cause(err, "read ca_path") + } + ca = string(data) + } + certificate := options.Certificate + if certificate == "" && options.CertificatePath != "" { + data, err := os.ReadFile(options.CertificatePath) + if err != nil { + return nil, E.Cause(err, "read certificate_path") + } + certificate = string(data) + } + key := options.Key + if key == "" && options.KeyPath != "" { + data, err := os.ReadFile(options.KeyPath) + if err != nil { + return nil, E.Cause(err, "read key_path") + } + key = string(data) + } + if strings.TrimSpace(ca) == "" { + return nil, E.New("openvpn: missing ca certificate") + } + if block, _ := pem.Decode([]byte(ca)); block == nil { + return nil, E.New("openvpn: ca is not valid PEM") + } + hasCert := strings.TrimSpace(certificate) != "" || strings.TrimSpace(key) != "" + if hasCert { + if strings.TrimSpace(certificate) == "" || strings.TrimSpace(key) == "" { + return nil, E.New("openvpn: certificate and key must both be set") + } + if block, _ := pem.Decode([]byte(certificate)); block == nil { + return nil, E.New("openvpn: certificate is not valid PEM") + } + if block, _ := pem.Decode([]byte(key)); block == nil { + return nil, E.New("openvpn: key is not valid PEM") + } + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM([]byte(ca)) { + return nil, E.New("openvpn: failed to parse ca certificate") + } + var tlsConfig tls.Config + tlsConfig.RootCAs = roots + tlsConfig.InsecureSkipVerify = true + if options.CipherSuites != nil { + find: + for _, cipherSuite := range options.CipherSuites { + for _, tlsCipherSuite := range tls.CipherSuites() { + if cipherSuite == tlsCipherSuite.Name { + tlsConfig.CipherSuites = append(tlsConfig.CipherSuites, tlsCipherSuite.ID) + continue find + } + } + return nil, E.New("unknown cipher_suite: ", cipherSuite) + } + } + tlsConfig.VerifyConnection = func(cs tls.ConnectionState) error { + if len(cs.PeerCertificates) == 0 { + return E.New("openvpn: server did not provide certificate") + } + cert := cs.PeerCertificates[0] + intermediates := x509.NewCertPool() + for _, intermediate := range cs.PeerCertificates[1:] { + intermediates.AddCert(intermediate) + } + _, err := cert.Verify(x509.VerifyOptions{ + Roots: roots, + Intermediates: intermediates, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }) + if err != nil { + return err + } + if options.VerifyX509Name != "" { + cn := cert.Subject.CommonName + switch options.VerifyX509NameMode { + case "name-prefix": + if !strings.HasPrefix(cn, options.VerifyX509Name) { + return E.New("openvpn: server CN ", cn, " does not match prefix ", options.VerifyX509Name) + } + case "name-suffix": + if !strings.HasSuffix(cn, options.VerifyX509Name) { + return E.New("openvpn: server CN ", cn, " does not match suffix ", options.VerifyX509Name) + } + default: + if cn != options.VerifyX509Name { + return E.New("openvpn: server CN ", cn, " does not match ", options.VerifyX509Name) + } + } + } + return nil + } + if hasCert { + cert, err := tls.X509KeyPair([]byte(certificate), []byte(key)) + if err != nil { + return nil, E.Cause(err, "openvpn: parse client certificate/key") + } + tlsConfig.Certificates = []tls.Certificate{cert} + } + var config Config = &STDClientConfig{ctx, &tlsConfig, false, 0, false} + if options.KernelRx || options.KernelTx { + if !C.IsLinux { + return nil, E.New("kTLS is only supported on Linux") + } + config = &KTLSClientConfig{ + Config: config, + logger: logger, + kernelTx: options.KernelTx, + kernelRx: options.KernelRx, + } + } + return config, nil +} diff --git a/constant/proxy.go b/constant/proxy.go index 1808e80e..cbb65868 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -13,10 +13,12 @@ const ( TypeShadowsocks = "shadowsocks" TypeVMess = "vmess" TypeTrojan = "trojan" + TypeTrustTunnel = "trusttunnel" TypeNaive = "naive" TypeWireGuard = "wireguard" TypeWARP = "warp" TypeMASQUE = "masque" + TypeOpenVPN = "openvpn" TypeMTProxy = "mtproxy" TypeParser = "parser" TypeHysteria = "hysteria" @@ -25,6 +27,7 @@ const ( TypeShadowTLS = "shadowtls" TypeMieru = "mieru" TypeAnyTLS = "anytls" + TypeSudoku = "sudoku" TypeShadowsocksR = "shadowsocksr" TypeVLESS = "vless" TypeTUIC = "tuic" @@ -84,6 +87,8 @@ func ProxyDisplayName(proxyType string) string { return "VMess" case TypeTrojan: return "Trojan" + case TypeTrustTunnel: + return "TrustTunnel" case TypeNaive: return "Naive" case TypeWireGuard: @@ -92,6 +97,8 @@ func ProxyDisplayName(proxyType string) string { return "WARP" case TypeMASQUE: return "MASQUE" + case TypeOpenVPN: + return "OpenVPN" case TypeMTProxy: return "MTProxy" case TypeParser: @@ -120,6 +127,8 @@ func ProxyDisplayName(proxyType string) string { return "Mieru" case TypeAnyTLS: return "AnyTLS" + case TypeSudoku: + return "Sudoku" case TypeFallback: return "Fallback" case TypeTailscale: diff --git a/examples/admin_panel-manager-node/manager.json b/examples/admin_panel-manager-node/manager.json index f097b775..bf9451cd 100644 --- a/examples/admin_panel-manager-node/manager.json +++ b/examples/admin_panel-manager-node/manager.json @@ -64,6 +64,8 @@ "listen_port": 7000, "manager": "my-manager", "api_key": "change-me-secret", + "keep_alive": "10s", + "keep_alive_timeout": "5s", // Enable TLS for production deployments (the node connects via gRPC over h2): // "tls": { // https://sing-box.sagernet.org/configuration/shared/tls/#inbound // "enabled": true, diff --git a/examples/amnezia/client.json b/examples/amnezia/client.json index d41691b1..2d5994d4 100644 --- a/examples/amnezia/client.json +++ b/examples/amnezia/client.json @@ -23,8 +23,7 @@ "address": "example.com", "port": 10001, "public_key": "3nk7jdnkcL95Fc/z+GCiH7jOovEKhFkLIGPT+U/uLEQ=", - "allowed_ips": ["0.0.0.0/0"], - "reserved": "AAAA" + "allowed_ips": ["0.0.0.0/0"] } ], "udp_timeout": "5m0s", diff --git a/examples/masque/client.json b/examples/masque/client.json index 410d1cd2..21c69aeb 100644 --- a/examples/masque/client.json +++ b/examples/masque/client.json @@ -29,7 +29,6 @@ "use_ipv6": false, "profile": { "detour": "direct", - // For getting existing MASQUE device profile, else sing-box will create new profile "id": "", "auth_token": "" }, @@ -37,14 +36,15 @@ "udp_keepalive_period": "30s", "udp_initial_packet_size": 0, "reconnect_delay": "5s", - // TLS fields for HTTP2 - "insecure": false, - "cipher_suites": [], - "curve_preferences": [], - "fragment": false, - "record_fragment": false, - "kernel_tx": false, - "kernel_rx": false + "tls": { + "insecure": false, + "cipher_suites": [], + "curve_preferences": [], + "fragment": false, + "record_fragment": false, + "kernel_tx": false, + "kernel_rx": false + } // Dial Fields } ], @@ -53,4 +53,4 @@ "default_domain_resolver": "default", "auto_detect_interface": true } -} \ No newline at end of file +} diff --git a/examples/openvpn/auth-user-pass.json b/examples/openvpn/auth-user-pass.json new file mode 100644 index 00000000..1b22a4ac --- /dev/null +++ b/examples/openvpn/auth-user-pass.json @@ -0,0 +1,46 @@ +{ + "log": { + "level": "info" + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "openvpn", + "tag": "openvpn-out", + "servers": [ + { + "server": "vpn.example.com", + "server_port": 1194 + } + ], + "proto": "udp", // udp, tcp + "username": "myuser", + "password": "mypassword", + "tls_crypt": "-----BEGIN OpenVPN Static key V1-----\n...\n-----END OpenVPN Static key V1-----", + // or: "tls_crypt_path": "/path/to/ta.key", + "tls": { + "ca": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", + // or: "ca_path": "/path/to/ca.crt", + "cipher_suites": [], + "verify_x509_name": "", + "verify_x509_name_mode": "", // name-prefix, name-suffix, exact (default) + "fragment": false, + "fragment_fallback_delay": "300ms", + "record_fragment": false, + "kernel_tx": false, + "kernel_rx": false + } + // Dial Fields + } + ], + "route": { + "final": "openvpn-out", + "auto_detect_interface": true + } +} diff --git a/examples/openvpn/tls-auth.json b/examples/openvpn/tls-auth.json new file mode 100644 index 00000000..8b86a89b --- /dev/null +++ b/examples/openvpn/tls-auth.json @@ -0,0 +1,51 @@ +{ + "log": { + "level": "info" + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "openvpn", + "tag": "openvpn-out", + "servers": [ + { + "server": "vpn.example.com", + "server_port": 1194 + } + ], + "proto": "udp", // udp, tcp + "cipher": "AES-256-CBC", + "auth": "SHA1", + "tls_auth": "-----BEGIN OpenVPN Static key V1-----\n...\n-----END OpenVPN Static key V1-----", + // or: "tls_auth_path": "/path/to/ta.key", + "key_direction": 1, + "tls": { + "certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", + // or: "certificate_path": "/path/to/client.crt", + "key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----", + // or: "key_path": "/path/to/client.key", + "ca": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", + // or: "ca_path": "/path/to/ca.crt", + "cipher_suites": [], + "verify_x509_name": "", + "verify_x509_name_mode": "", // name-prefix, name-suffix, exact (default) + "fragment": false, + "fragment_fallback_delay": "300ms", + "record_fragment": false, + "kernel_tx": false, + "kernel_rx": false + } + // Dial Fields + } + ], + "route": { + "final": "openvpn-out", + "auto_detect_interface": true + } +} diff --git a/examples/openvpn/tls-crypt-v2.json b/examples/openvpn/tls-crypt-v2.json new file mode 100644 index 00000000..ed4d3f4f --- /dev/null +++ b/examples/openvpn/tls-crypt-v2.json @@ -0,0 +1,49 @@ +{ + "log": { + "level": "info" + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "openvpn", + "tag": "openvpn-out", + "servers": [ + { + "server": "vpn.example.com", + "server_port": 1194 + } + ], + "proto": "udp", // udp, tcp + "tls_crypt": "-----BEGIN OpenVPN tls-crypt-v2 client key-----\n...\n-----END OpenVPN tls-crypt-v2 client key-----", + // or: "tls_crypt_path": "/path/to/tls-crypt-v2.key", + "tls_crypt_v2": true, + "tls": { + "certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", + // or: "certificate_path": "/path/to/client.crt", + "key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----", + // or: "key_path": "/path/to/client.key", + "ca": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", + // or: "ca_path": "/path/to/ca.crt", + "cipher_suites": [], + "verify_x509_name": "", + "verify_x509_name_mode": "", // name-prefix, name-suffix, exact (default) + "fragment": false, + "fragment_fallback_delay": "300ms", + "record_fragment": false, + "kernel_tx": false, + "kernel_rx": false + } + // Dial Fields + } + ], + "route": { + "final": "openvpn-out", + "auto_detect_interface": true + } +} diff --git a/examples/openvpn/tls-crypt.json b/examples/openvpn/tls-crypt.json new file mode 100644 index 00000000..04599ece --- /dev/null +++ b/examples/openvpn/tls-crypt.json @@ -0,0 +1,52 @@ +{ + "log": { + "level": "info" + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "openvpn", + "tag": "openvpn-out", + "servers": [ + { + "server": "vpn.example.com", + "server_port": 1194 + } + ], + "proto": "udp", // udp, tcp + "cipher": "AES-256-GCM", // AES-128-GCM, AES-192-GCM, AES-256-GCM, AES-128-CBC, AES-192-CBC, AES-256-CBC, CHACHA20-POLY1305 + "auth": "SHA256", // SHA1, SHA256, SHA384, SHA512 (ignored for AEAD ciphers) + "tls_crypt": "-----BEGIN OpenVPN Static key V1-----\n...\n-----END OpenVPN Static key V1-----", + // or: "tls_crypt_path": "/path/to/ta.key", + "ping_interval": "10s", + "reconnect_delay": "30s", + "tls": { + "certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", + // or: "certificate_path": "/path/to/client.crt", + "key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----", + // or: "key_path": "/path/to/client.key", + "ca": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", + // or: "ca_path": "/path/to/ca.crt", + "cipher_suites": [], + "verify_x509_name": "", + "verify_x509_name_mode": "", // name-prefix, name-suffix, exact (default) + "fragment": false, + "fragment_fallback_delay": "300ms", + "record_fragment": false, + "kernel_tx": false, + "kernel_rx": false + } + // Dial Fields + } + ], + "route": { + "final": "openvpn-out", + "auto_detect_interface": true + } +} diff --git a/examples/provider/local.json b/examples/provider/local.json index 00b7f136..5f85e917 100644 --- a/examples/provider/local.json +++ b/examples/provider/local.json @@ -48,6 +48,8 @@ // - SIP008 (shadowsocks) // - Raw shareable links (vless://, vmess://, ss://, trojan://, ...) "path": "subscriptions/my-sub.txt", + // Remove emoji flags from proxy names. + "remove_emojis": true, "health_check": { "enabled": true, "url": "https://www.gstatic.com/generate_204", diff --git a/examples/provider/remote.json b/examples/provider/remote.json index ce8a4018..4616f7f9 100644 --- a/examples/provider/remote.json +++ b/examples/provider/remote.json @@ -56,6 +56,8 @@ // "exclude" wins over "include" when both match. "exclude": "(?i)expire|流量|官网", "include": "(?i)hk|jp|sg|us", + // Remove emoji flags from proxy names. + "remove_emojis": true, "health_check": { "enabled": true, "url": "https://www.gstatic.com/generate_204", diff --git a/examples/sudoku/client.json b/examples/sudoku/client.json new file mode 100644 index 00000000..3c47758b --- /dev/null +++ b/examples/sudoku/client.json @@ -0,0 +1,17 @@ +{ + "inbounds": [ + { + "type": "mixed", + "listen": "127.0.0.1", + "listen_port": 1080 + } + ], + "outbounds": [ + { + "type": "sudoku", + "server": "your-server.com", + "server_port": 443, + "key": "your-secret-key" + } + ] +} diff --git a/examples/sudoku/client_tls.json b/examples/sudoku/client_tls.json new file mode 100644 index 00000000..1d0ee27a --- /dev/null +++ b/examples/sudoku/client_tls.json @@ -0,0 +1,29 @@ +{ + "inbounds": [ + { + "type": "mixed", + "listen": "127.0.0.1", + "listen_port": 1080 + } + ], + "outbounds": [ + { + "type": "sudoku", + "server": "your-server.com", + "server_port": 443, + "key": "your-secret-key", + "tls": { + "enabled": true, + "fragment": true, + "fragment_fallback_delay": "300ms" + }, + "http_mask": { + "enabled": true, + "mode": "stream", + "host": "cdn.example.com", + "path_root": "secret", + "multiplex": "auto" + } + } + ] +} diff --git a/examples/sudoku/server.json b/examples/sudoku/server.json new file mode 100644 index 00000000..a93a8f94 --- /dev/null +++ b/examples/sudoku/server.json @@ -0,0 +1,15 @@ +{ + "inbounds": [ + { + "type": "sudoku", + "listen": "::", + "listen_port": 443, + "key": "your-secret-key" + } + ], + "outbounds": [ + { + "type": "direct" + } + ] +} diff --git a/examples/sudoku/server_advanced.json b/examples/sudoku/server_advanced.json new file mode 100644 index 00000000..bcb8534c --- /dev/null +++ b/examples/sudoku/server_advanced.json @@ -0,0 +1,22 @@ +{ + "inbounds": [ + { + "type": "sudoku", + "listen": "::", + "listen_port": 443, + "key": "your-secret-key", + "aead_method": "aes-128-gcm", + "table_type": "prefer_entropy", + "padding_min": 10, + "padding_max": 50, + "http_mask_mode": "stream", + "path_root": "secret", + "fallback": "127.0.0.1:8080" + } + ], + "outbounds": [ + { + "type": "direct" + } + ] +} diff --git a/examples/trusttunnel/client.json b/examples/trusttunnel/client.json new file mode 100644 index 00000000..505a1b77 --- /dev/null +++ b/examples/trusttunnel/client.json @@ -0,0 +1,78 @@ +{ + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "trusttunnel", + "tag": "trusttunnel-h2", + "server": "example.com", + "server_port": 443, + "username": "user1", + "password": "password1", + "network": ["tcp", "udp"], + "health_check": true, + "multiplex": { + "enabled": true, + "max_connections": 8, + "min_streams": 5 + }, + "tls": { + "enabled": true, + "server_name": "example.com" + } + // Dial Fields + }, + { + "type": "trusttunnel", + "tag": "trusttunnel-quic", + "server": "example.com", + "server_port": 443, + "username": "user1", + "password": "password1", + "network": ["tcp", "udp"], + "health_check": true, + "quic": true, + "congestion_controller": "bbr", // bbr, bbr_standard, bbr2, bbr2_variant, cubic, reno + "bbr_profile": "standard", // standard, conservative, aggressive + "cwnd": 32, + "multiplex": { + "enabled": true, + "max_connections": 8, + "min_streams": 5 + }, + "tls": { + "enabled": true, + "server_name": "example.com" + } + // Dial Fields + }, + { + "type": "selector", + "tag": "trusttunnel-selector", + "outbounds": ["trusttunnel-h2", "trusttunnel-quic"], + "default": "trusttunnel-h2" + } + ], + "route": { + "final": "trusttunnel-selector", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/trusttunnel/server.json b/examples/trusttunnel/server.json new file mode 100644 index 00000000..db8e5508 --- /dev/null +++ b/examples/trusttunnel/server.json @@ -0,0 +1,32 @@ +{ + "inbounds": [ + { + "type": "trusttunnel", + "tag": "trusttunnel-in", + "listen": "::", + "listen_port": 443, + "network": ["tcp", "udp"], + "users": [ + { + "name": "user1", + "password": "password1" + } + ], + "congestion_controller": "bbr", // bbr, bbr_standard, bbr2, bbr2_variant, cubic, reno + "bbr_profile": "standard", // standard, conservative, aggressive + "cwnd": 32, + "tls": { + "enabled": true, + "alpn": ["h2", "h3"], + "certificate_path": "/path/to/cert.pem", + "key_path": "/path/to/key.pem" + } + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + } + ] +} diff --git a/examples/wireguard/client.json b/examples/wireguard/client.json index d791c15b..6f7946b8 100644 --- a/examples/wireguard/client.json +++ b/examples/wireguard/client.json @@ -23,8 +23,7 @@ "address": "example.com", "port": 10001, "public_key": "3nk7jdnkcL95Fc/z+GCiH7jOovEKhFkLIGPT+U/uLEQ=", - "allowed_ips": ["0.0.0.0/0"], - "reserved": "AAAA" + "allowed_ips": ["0.0.0.0/0"] } ], "udp_timeout": "5m0s", diff --git a/go.mod b/go.mod index afdaa53e..749e222a 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/render v1.0.3 github.com/go-playground/validator/v10 v10.30.1 + github.com/gobwas/ws v1.4.0 github.com/godbus/dbus/v5 v5.2.2 github.com/gofrs/uuid/v5 v5.4.0 github.com/golang-migrate/migrate/v4 v4.19.1 @@ -53,7 +54,7 @@ require ( github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.0.2 - github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 + github.com/shtorm-7/workerpool v0.5.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/vishvananda/netns v0.0.5 @@ -75,17 +76,24 @@ require ( require ( github.com/OneOfOne/xxhash v1.2.8 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/docker/docker v28.5.2+incompatible // indirect + github.com/docker/go-connections v0.6.0 // indirect github.com/dunglas/httpsfv v1.1.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/lib/pq v1.10.9 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/panjf2000/ants/v2 v2.12.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/redis/go-redis/v9 v9.8.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/tylertreat/BoomFilters v0.0.0-20251117164519-53813c36cc1b // indirect + github.com/zeebo/assert v1.3.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect gvisor.dev/gvisor v0.0.0-20260408064518-65a410b0d584 // indirect modernc.org/libc v1.72.0 // indirect modernc.org/mathutil v1.7.1 // indirect @@ -93,14 +101,14 @@ require ( ) require ( - filippo.io/edwards25519 v1.1.0 // indirect + filippo.io/edwards25519 v1.1.0 github.com/AdguardTeam/golibs v0.32.7 // indirect github.com/ajg/form v1.5.1 // indirect github.com/akutz/memconn v0.1.0 // indirect github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect github.com/ameshkov/dnscrypt/v2 v2.4.0 github.com/ameshkov/dnsstamps v1.0.3 // indirect - github.com/andybalholm/brotli v1.1.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect github.com/caddyserver/zerossl v0.1.5 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect @@ -144,7 +152,7 @@ require ( github.com/mdlayher/netlink v1.9.0 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect - github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/pierrec/lz4/v4 v4.1.25 // indirect github.com/pires/go-proxyproto v0.11.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus-community/pro-bing v0.4.0 // indirect @@ -219,10 +227,12 @@ replace github.com/sagernet/sing-mux => github.com/shtorm-7/sing-mux v0.3.4-exte replace github.com/ameshkov/dnscrypt/v2 => github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0 -replace github.com/sagernet/sing-vmess => github.com/starifly/sing-vmess v0.2.7-mod.9 +replace github.com/sagernet/sing-vmess => github.com/shtorm-7/sing-vmess v0.2.7-extended-1.0.0 replace github.com/dolonet/mtg-multi => github.com/shtorm-7/mtg-multi v1.8.0-extended-1.0.1 replace github.com/Diniboy1123/connect-ip-go => github.com/shtorm-7/connect-ip-go v1.0.0-extended-1.0.0 replace github.com/shtorm-7/go-cache/v2 => github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.1.0 + +replace github.com/sagernet/sing => github.com/shtorm-7/sing v0.8.10-extended-1.0.0 diff --git a/go.sum b/go.sum index ba66c882..466266ab 100644 --- a/go.sum +++ b/go.sum @@ -20,14 +20,16 @@ github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7V github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/ameshkov/dnsstamps v1.0.3 h1:Srzik+J9mivH1alRACTbys2xOxs0lRH9qnTA7Y1OYVo= github.com/ameshkov/dnsstamps v1.0.3/go.mod h1:Ii3eUu73dx4Vw5O4wjzmT5+lkCwovjzaEZZ4gKyIH5A= -github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= -github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc= github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8= github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6 h1:4NNbNM2Iq/k57qEu7WfL67UrbPq1uFWxW4qODCohi+0= github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6/go.mod h1:J29hk+f9lJrblVIfiJOtTFk+OblBawmib4uz/VdKzlg= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -75,10 +77,10 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= -github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= -github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54= @@ -129,12 +131,12 @@ github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= @@ -243,16 +245,16 @@ github.com/openai/openai-go/v3 v3.26.0 h1:bRt6H/ozMNt/dDkN4gobnLqaEGrRGBzmbVs0xx github.com/openai/openai-go/v3 v3.26.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= github.com/panjf2000/ants/v2 v2.12.0 h1:u9JhESo83i/GkZnhfTNuFMMWcNt7mnV1bGJ6FT4wXH8= github.com/panjf2000/ants/v2 v2.12.0/go.mod h1:tSQuaNQ6r6NRhPt+IZVUevvDyFMTs+eS4ztZc52uJTY= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= -github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= -github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= +github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4= github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -357,8 +359,6 @@ github.com/sagernet/nftables v0.3.0-mod.2 h1:ck2KMU02OxL1eDFgGaWYglMDpoOZ7OHzxje github.com/sagernet/nftables v0.3.0-mod.2/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= -github.com/sagernet/sing v0.8.10 h1:V5VZffy8rm4dtBVKIpKa8vibRR2SiJprtu/10DFUalU= -github.com/sagernet/sing v0.8.10/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= github.com/sagernet/sing-quic v0.6.1 h1:lx0tcm99wIA1RkyvILNzRSsMy1k7TTQYIhx71E/WBlw= github.com/sagernet/sing-quic v0.6.1/go.mod h1:K5bWvITOm4vE10fwLfrWpw27bCoVJ+tfQ79tOWg+Ko8= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= @@ -381,21 +381,23 @@ github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.1.0 h1:PLZ/YHqnApPx13wt6MX3Itq github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.1.0/go.mod h1:Ek4yz5OK6stwhLKgLsRRYDI+FA+ZWvRJiWLjsi/vMM4= github.com/shtorm-7/mtg-multi v1.8.0-extended-1.0.1 h1:UeJkrCJJmIjTBywErVMx7fCSoBf4gh6QgT9bp9o1ajM= github.com/shtorm-7/mtg-multi v1.8.0-extended-1.0.1/go.mod h1:3rvdhwdPABkwKBdvgMt3VwMn9uSq8hpoHRezZ5jRJU0= +github.com/shtorm-7/sing v0.8.10-extended-1.0.0 h1:mAkyycCQOzCttPOR5fcHkJaZvXMQXeu3mbEfr8D+7A8= +github.com/shtorm-7/sing v0.8.10-extended-1.0.0/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0 h1:a5OoXr3e2ACbM6vDIaaGL44IdHQ6wPjcSoU13vfC0Sw= github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= +github.com/shtorm-7/sing-vmess v0.2.7-extended-1.0.0 h1:WVheKmQH5hSQbJU1ZTKthKSutkTLWSb2hp4JuQhJBow= +github.com/shtorm-7/sing-vmess v0.2.7-extended-1.0.0/go.mod h1:5aYoOtYksAyS0NXDm0qKeTYW1yoE1bJVcv+XLcVoyJs= github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.7-extended-1.0.2 h1:hSMjh97OszszOd8HrzpaYUQH9dWRRBluJCbwQyz8ZOk= github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.7-extended-1.0.2/go.mod h1:TYIIqO5sZpWq873rLIeO2usszSMUpR3h6WdqVVs65ug= github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.4.3 h1:jtOA73D4F5qRV70//ahOt20KBnWvQimAFjtIiOtt0ps= github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.4.3/go.mod h1:Me2JlCDYHxnd0mnuX7L5LXAeDHCltI7vSKq3eTE6SVE= -github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8= -github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E= +github.com/shtorm-7/workerpool v0.5.0 h1:NPZuNgyH0EUm4aQsTL09xR1iV+7GCFw6jX9Z4aAVp2s= +github.com/shtorm-7/workerpool v0.5.0/go.mod h1:NI0pUZgmGu0BdKO9j3mct1DNZmgXbyTS9foorljdH6E= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/starifly/sing-vmess v0.2.7-mod.9 h1:xobAmejSbBQ0A3f/EtJ9cJd3m6gK7dDPccPdeGz7tXY= -github.com/starifly/sing-vmess v0.2.7-mod.9/go.mod h1:5aYoOtYksAyS0NXDm0qKeTYW1yoE1bJVcv+XLcVoyJs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= @@ -443,12 +445,14 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU= github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= -github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= @@ -457,16 +461,16 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= @@ -521,8 +525,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= -golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= diff --git a/include/openvpn.go b/include/openvpn.go new file mode 100644 index 00000000..95e7dcf6 --- /dev/null +++ b/include/openvpn.go @@ -0,0 +1,12 @@ +//go:build with_openvpn + +package include + +import ( + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/protocol/openvpn" +) + +func registerOpenVPNOutbound(registry *outbound.Registry) { + openvpn.RegisterOutbound(registry) +} diff --git a/include/openvpn_stub.go b/include/openvpn_stub.go new file mode 100644 index 00000000..05eb2b33 --- /dev/null +++ b/include/openvpn_stub.go @@ -0,0 +1,20 @@ +//go:build !with_openvpn + +package include + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func registerOpenVPNOutbound(registry *outbound.Registry) { + outbound.Register[option.OpenVPNOutboundOptions](registry, C.TypeOpenVPN, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.OpenVPNOutboundOptions) (adapter.Outbound, error) { + return nil, E.New(`OpenVPN outbound is not included in this build, rebuild with -tags with_openvpn`) + }) +} diff --git a/include/registry.go b/include/registry.go index cabbb4c5..cd4514bd 100644 --- a/include/registry.go +++ b/include/registry.go @@ -31,6 +31,7 @@ import ( "github.com/sagernet/sing-box/protocol/limiter/rate" "github.com/sagernet/sing-box/protocol/limiter/traffic" "github.com/sagernet/sing-box/protocol/mieru" + "github.com/sagernet/sing-box/protocol/mixed" "github.com/sagernet/sing-box/protocol/naive" "github.com/sagernet/sing-box/protocol/parser" @@ -83,10 +84,12 @@ func InboundRegistry() *inbound.Registry { bond.RegisterInbound(registry) failover.RegisterInbound(registry) + registerTrustTunnelInbound(registry) registerQUICInbounds(registry) registerStubForRemovedInbounds(registry) registerMTProxyInbound(registry) + registerSudokuInbound(registry) return registry } @@ -115,9 +118,11 @@ func OutboundRegistry() *outbound.Registry { mieru.RegisterOutbound(registry) anytls.RegisterOutbound(registry) registerMASQUEOutbound(registry) + registerOpenVPNOutbound(registry) bond.RegisterOutbound(registry) failover.RegisterOutbound(registry) + registerTrustTunnelOutbound(registry) bandwidth.RegisterOutbound(registry) connection.RegisterOutbound(registry) @@ -128,6 +133,7 @@ func OutboundRegistry() *outbound.Registry { registerQUICOutbounds(registry) registerStubForRemovedOutbounds(registry) + registerSudokuOutbound(registry) return registry } diff --git a/include/sudoku.go b/include/sudoku.go new file mode 100644 index 00000000..9f3ae028 --- /dev/null +++ b/include/sudoku.go @@ -0,0 +1,17 @@ +//go:build with_sudoku + +package include + +import ( + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/protocol/sudoku" +) + +func registerSudokuInbound(registry *inbound.Registry) { + sudoku.RegisterInbound(registry) +} + +func registerSudokuOutbound(registry *outbound.Registry) { + sudoku.RegisterOutbound(registry) +} diff --git a/include/sudoku_stub.go b/include/sudoku_stub.go new file mode 100644 index 00000000..70a1e85c --- /dev/null +++ b/include/sudoku_stub.go @@ -0,0 +1,27 @@ +//go:build !with_sudoku + +package include + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/adapter/outbound" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func registerSudokuInbound(registry *inbound.Registry) { + inbound.Register[option.SudokuInboundOptions](registry, C.TypeSudoku, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SudokuInboundOptions) (adapter.Inbound, error) { + return nil, E.New(`Sudoku is not included in this build, rebuild with -tags with_sudoku`) + }) +} + +func registerSudokuOutbound(registry *outbound.Registry) { + outbound.Register[option.SudokuOutboundOptions](registry, C.TypeSudoku, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SudokuOutboundOptions) (adapter.Outbound, error) { + return nil, E.New(`Sudoku is not included in this build, rebuild with -tags with_sudoku`) + }) +} diff --git a/include/trusttunnel.go b/include/trusttunnel.go new file mode 100644 index 00000000..a422814f --- /dev/null +++ b/include/trusttunnel.go @@ -0,0 +1,17 @@ +//go:build with_trusttunnel + +package include + +import ( + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/protocol/trusttunnel" +) + +func registerTrustTunnelInbound(registry *inbound.Registry) { + trusttunnel.RegisterInbound(registry) +} + +func registerTrustTunnelOutbound(registry *outbound.Registry) { + trusttunnel.RegisterOutbound(registry) +} diff --git a/include/trusttunnel_stub.go b/include/trusttunnel_stub.go new file mode 100644 index 00000000..9aaeb492 --- /dev/null +++ b/include/trusttunnel_stub.go @@ -0,0 +1,27 @@ +//go:build !with_trusttunnel + +package include + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/adapter/outbound" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func registerTrustTunnelInbound(registry *inbound.Registry) { + inbound.Register[option.TrustTunnelInboundOptions](registry, C.TypeTrustTunnel, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TrustTunnelInboundOptions) (adapter.Inbound, error) { + return nil, E.New(`TrustTunnel is not included in this build, rebuild with -tags with_trusttunnel`) + }) +} + +func registerTrustTunnelOutbound(registry *outbound.Registry) { + outbound.Register[option.TrustTunnelOutboundOptions](registry, C.TypeTrustTunnel, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TrustTunnelOutboundOptions) (adapter.Outbound, error) { + return nil, E.New(`TrustTunnel is not included in this build, rebuild with -tags with_trusttunnel`) + }) +} diff --git a/option/manager.go b/option/manager.go index f8ee2f6c..a138e14d 100644 --- a/option/manager.go +++ b/option/manager.go @@ -6,6 +6,6 @@ type ManagerServiceDatabase struct { } type ManagerServiceOptions struct { - Inbounds []string `json:"inbounds"` + Inbounds []string `json:"inbounds"` Database ManagerServiceDatabase `json:"database"` } diff --git a/option/masque.go b/option/masque.go index 83fa849a..18751b4b 100644 --- a/option/masque.go +++ b/option/masque.go @@ -5,6 +5,7 @@ import ( ) type MASQUEOutboundOptions struct { + DialerOptions UseHTTP2 bool `json:"use_http2,omitempty"` UseIPv6 bool `json:"use_ipv6,omitempty"` Profile CloudflareProfile `json:"profile,omitempty"` @@ -12,8 +13,7 @@ type MASQUEOutboundOptions struct { UDPKeepalivePeriod badoption.Duration `json:"udp_keepalive_period,omitempty"` UDPInitialPacketSize uint16 `json:"udp_initial_packet_size,omitempty"` ReconnectDelay badoption.Duration `json:"reconnect_delay,omitempty"` - MASQUEOutboundTLSOptions - DialerOptions + MASQUEOutboundTLSOptionsContainer } type MASQUEOutboundTLSOptions struct { @@ -28,5 +28,5 @@ type MASQUEOutboundTLSOptions struct { } type MASQUEOutboundTLSOptionsContainer struct { - TLS *OutboundTLSOptions `json:"tls,omitempty"` + TLS *MASQUEOutboundTLSOptions `json:"tls,omitempty"` } diff --git a/option/node.go b/option/node.go index 3c4afaef..1e24d3eb 100644 --- a/option/node.go +++ b/option/node.go @@ -1,11 +1,11 @@ package option type NodeServiceOptions struct { - UUID string - Inbounds []string `json:"inbounds"` - ConnectionLimiters []string `json:"connection_limiters"` - BandwidthLimiters []string `json:"bandwidth_limiters"` - TrafficLimiters []string `json:"traffic_limiters"` - RateLimiters []string `json:"rate_limiters"` - Manager string `json:"manager"` + UUID string `json:"uuid"` + Inbounds []string `json:"inbounds"` + ConnectionLimiters []string `json:"connection_limiters"` + BandwidthLimiters []string `json:"bandwidth_limiters"` + TrafficLimiters []string `json:"traffic_limiters"` + RateLimiters []string `json:"rate_limiters"` + Manager string `json:"manager"` } diff --git a/option/node_manager_api.go b/option/node_manager_api.go index b0b94599..26e83930 100644 --- a/option/node_manager_api.go +++ b/option/node_manager_api.go @@ -5,6 +5,7 @@ import ( E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/common/json/badoption" ) type _NodeManagerAPIOptions struct { @@ -57,8 +58,10 @@ func (o *NodeManagerAPIOptions) UnmarshalJSON(bytes []byte) error { type NodeManagerAPIServerOptions struct { ListenOptions InboundTLSOptionsContainer - Manager string `json:"manager"` - APIKey string `json:"api_key"` + Manager string `json:"manager"` + APIKey string `json:"api_key"` + KeepAlive badoption.Duration `json:"keep_alive,omitempty"` + KeepAliveTimeout badoption.Duration `json:"keep_alive_timeout,omitempty"` } type NodeManagerAPIClientOptions struct { diff --git a/option/openvpn.go b/option/openvpn.go new file mode 100644 index 00000000..e2744995 --- /dev/null +++ b/option/openvpn.go @@ -0,0 +1,40 @@ +package option + +import "github.com/sagernet/sing/common/json/badoption" + +type OpenVPNOutboundOptions struct { + DialerOptions + Servers []ServerOptions `json:"servers"` + Proto string `json:"proto,omitempty"` + Cipher string `json:"cipher,omitempty"` + Auth string `json:"auth,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + TLSCrypt string `json:"tls_crypt,omitempty"` + TLSCryptPath string `json:"tls_crypt_path,omitempty"` + TLSCryptV2 bool `json:"tls_crypt_v2,omitempty"` + TLSAuth string `json:"tls_auth,omitempty"` + TLSAuthPath string `json:"tls_auth_path,omitempty"` + KeyDirection int `json:"key_direction,omitempty"` + ReconnectDelay badoption.Duration `json:"reconnect_delay,omitempty"` + PingInterval badoption.Duration `json:"ping_interval,omitempty"` + OpenVPNOutboundTLSOptionsContainer +} + +type OpenVPNTLSOptions struct { + Certificate string `json:"certificate,omitempty"` + CertificatePath string `json:"certificate_path,omitempty"` + Key string `json:"key,omitempty"` + KeyPath string `json:"key_path,omitempty"` + CA string `json:"ca,omitempty"` + CAPath string `json:"ca_path,omitempty"` + CipherSuites badoption.Listable[string] `json:"cipher_suites,omitempty"` + VerifyX509Name string `json:"verify_x509_name,omitempty"` + VerifyX509NameMode string `json:"verify_x509_name_mode,omitempty"` + KernelTx bool `json:"kernel_tx,omitempty"` + KernelRx bool `json:"kernel_rx,omitempty"` +} + +type OpenVPNOutboundTLSOptionsContainer struct { + TLS *OpenVPNTLSOptions `json:"tls,omitempty"` +} diff --git a/option/provider.go b/option/provider.go index 656036e4..9f11422a 100644 --- a/option/provider.go +++ b/option/provider.go @@ -47,8 +47,9 @@ func (h *Provider) UnmarshalJSONContext(ctx context.Context, content []byte) err } type ProviderLocalOptions struct { - Path string `json:"path"` - HealthCheck ProviderHealthCheckOptions `json:"health_check,omitempty"` + Path string `json:"path"` + RemoveEmojis bool `json:"remove_emojis,omitempty"` + HealthCheck ProviderHealthCheckOptions `json:"health_check,omitempty"` } type ProviderRemoteOptions struct { @@ -57,14 +58,16 @@ type ProviderRemoteOptions struct { DownloadDetour string `json:"download_detour,omitempty"` UpdateInterval badoption.Duration `json:"update_interval,omitempty"` - Exclude *badoption.Regexp `json:"exclude,omitempty"` - Include *badoption.Regexp `json:"include,omitempty"` - HealthCheck ProviderHealthCheckOptions `json:"health_check,omitempty"` + Exclude *badoption.Regexp `json:"exclude,omitempty"` + Include *badoption.Regexp `json:"include,omitempty"` + RemoveEmojis bool `json:"remove_emojis,omitempty"` + HealthCheck ProviderHealthCheckOptions `json:"health_check,omitempty"` } type ProviderInlineOptions struct { - Outbounds []Outbound `json:"outbounds,omitempty"` - HealthCheck ProviderHealthCheckOptions `json:"health_check,omitempty"` + Outbounds []Outbound `json:"outbounds,omitempty"` + RemoveEmojis bool `json:"remove_emojis,omitempty"` + HealthCheck ProviderHealthCheckOptions `json:"health_check,omitempty"` } type ProviderHealthCheckOptions struct { diff --git a/option/sudoku.go b/option/sudoku.go new file mode 100644 index 00000000..ceb314b3 --- /dev/null +++ b/option/sudoku.go @@ -0,0 +1,54 @@ +package option + +import "github.com/sagernet/sing/common/json/badoption" + +type SudokuOutboundOptions struct { + DialerOptions + ServerOptions + Key string `json:"key"` + AEADMethod string `json:"aead_method,omitempty"` + PaddingMin *int `json:"padding_min,omitempty"` + PaddingMax *int `json:"padding_max,omitempty"` + TableType string `json:"table_type,omitempty"` + EnablePureDownlink *bool `json:"enable_pure_downlink,omitempty"` + CustomTable string `json:"custom_table,omitempty"` + CustomTables []string `json:"custom_tables,omitempty"` + HTTPMask *SudokuHTTPMask `json:"http_mask,omitempty"` +} + +type SudokuHTTPMask struct { + Enabled bool `json:"enabled,omitempty"` + Mode string `json:"mode,omitempty"` + Host string `json:"host,omitempty"` + PathRoot string `json:"path_root,omitempty"` + Multiplex string `json:"multiplex,omitempty"` + TLS *SudokuOutboundTLSOptions `json:"tls,omitempty"` +} + +type SudokuInboundOptions struct { + ListenOptions + Key string `json:"key"` + AEADMethod string `json:"aead_method,omitempty"` + PaddingMin *int `json:"padding_min,omitempty"` + PaddingMax *int `json:"padding_max,omitempty"` + TableType string `json:"table_type,omitempty"` + HandshakeTimeout *int `json:"handshake_timeout,omitempty"` + EnablePureDownlink *bool `json:"enable_pure_downlink,omitempty"` + CustomTable string `json:"custom_table,omitempty"` + CustomTables []string `json:"custom_tables,omitempty"` + DisableHTTPMask bool `json:"disable_http_mask,omitempty"` + HTTPMaskMode string `json:"http_mask_mode,omitempty"` + PathRoot string `json:"path_root,omitempty"` + Fallback string `json:"fallback,omitempty"` +} + +type SudokuOutboundTLSOptions struct { + Enabled bool `json:"enabled,omitempty"` + Fragment bool `json:"fragment,omitempty"` + FragmentFallbackDelay badoption.Duration `json:"fragment_fallback_delay,omitempty"` + RecordFragment bool `json:"record_fragment,omitempty"` + KernelTx bool `json:"kernel_tx,omitempty"` + KernelRx bool `json:"kernel_rx,omitempty"` +} + + diff --git a/option/trusttunnel.go b/option/trusttunnel.go new file mode 100644 index 00000000..61342dd7 --- /dev/null +++ b/option/trusttunnel.go @@ -0,0 +1,38 @@ +package option + +type TrustTunnelInboundOptions struct { + ListenOptions + InboundTLSOptionsContainer + Users []TrustTunnelUser `json:"users,omitempty"` + Network NetworkList `json:"network,omitempty"` + CongestionController string `json:"congestion_controller,omitempty"` + BBRProfile string `json:"bbr_profile,omitempty"` + CWND int `json:"cwnd,omitempty"` +} + +type TrustTunnelUser struct { + Name string `json:"name,omitempty"` + Password string `json:"password,omitempty"` +} + +type TrustTunnelMultiplexOptions struct { + Enabled bool `json:"enabled,omitempty"` + MaxConnections int `json:"max_connections,omitempty"` + MinStreams int `json:"min_streams,omitempty"` + MaxStreams int `json:"max_streams,omitempty"` +} + +type TrustTunnelOutboundOptions struct { + DialerOptions + ServerOptions + OutboundTLSOptionsContainer + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + Network NetworkList `json:"network,omitempty"` + HealthCheck bool `json:"health_check,omitempty"` + QUIC bool `json:"quic,omitempty"` + CongestionController string `json:"congestion_controller,omitempty"` + BBRProfile string `json:"bbr_profile,omitempty"` + CWND int `json:"cwnd,omitempty"` + Multiplex *TrustTunnelMultiplexOptions `json:"multiplex,omitempty"` +} diff --git a/protocol/anytls/inbound.go b/protocol/anytls/inbound.go index 98168146..c6be74ba 100644 --- a/protocol/anytls/inbound.go +++ b/protocol/anytls/inbound.go @@ -96,6 +96,12 @@ func (h *Inbound) Close() error { return common.Close(h.listener, h.tlsConfig) } +func (h *Inbound) UpdateUsers(users []option.AnyTLSUser) { + h.service.UpdateUsers(common.Map(users, func(it option.AnyTLSUser) anytls.User { + return anytls.User(it) + })) +} + func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { if h.tlsConfig != nil { tlsConn, err := tls.ServerHandshake(ctx, conn, h.tlsConfig) diff --git a/protocol/http/inbound.go b/protocol/http/inbound.go index e8a9a3da..0f14b7d4 100644 --- a/protocol/http/inbound.go +++ b/protocol/http/inbound.go @@ -86,6 +86,10 @@ func (h *Inbound) Close() error { ) } +func (h *Inbound) UpdateUsers(users []auth.User) { + h.authenticator.UpdateUsers(users) +} + func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { if h.tlsConfig != nil { tlsConn, err := tls.ServerHandshake(ctx, conn, h.tlsConfig) diff --git a/protocol/limiter/bandwidth/conn.go b/protocol/limiter/bandwidth/conn.go index dffbfbd7..fdbde584 100644 --- a/protocol/limiter/bandwidth/conn.go +++ b/protocol/limiter/bandwidth/conn.go @@ -3,15 +3,17 @@ package bandwidth import ( "context" "net" + + "github.com/sagernet/sing-box/common/onclose" ) type connWithDownloadBandwidthLimiter struct { net.Conn ctx context.Context - limiter Limiter + limiter BandwidthLimiter } -func NewConnWithDownloadBandwidthLimiter(ctx context.Context, conn net.Conn, limiter Limiter) *connWithDownloadBandwidthLimiter { +func NewConnWithDownloadBandwidthLimiter(ctx context.Context, conn net.Conn, limiter BandwidthLimiter) *connWithDownloadBandwidthLimiter { return &connWithDownloadBandwidthLimiter{conn, ctx, limiter} } @@ -26,10 +28,10 @@ func (conn *connWithDownloadBandwidthLimiter) Write(p []byte) (n int, err error) type connWithUploadBandwidthLimiter struct { net.Conn ctx context.Context - limiter Limiter + limiter BandwidthLimiter } -func NewConnWithUploadBandwidthLimiter(ctx context.Context, conn net.Conn, limiter Limiter) *connWithUploadBandwidthLimiter { +func NewConnWithUploadBandwidthLimiter(ctx context.Context, conn net.Conn, limiter BandwidthLimiter) *connWithUploadBandwidthLimiter { return &connWithUploadBandwidthLimiter{conn, ctx, limiter} } @@ -47,10 +49,10 @@ func (conn *connWithUploadBandwidthLimiter) Read(p []byte) (n int, err error) { type connWithCloseHandler struct { net.Conn - onClose CloseHandlerFunc + onClose onclose.CloseHandlerFunc } -func NewConnWithCloseHandler(conn net.Conn, onClose CloseHandlerFunc) *connWithCloseHandler { +func NewConnWithCloseHandler(conn net.Conn, onClose onclose.CloseHandlerFunc) *connWithCloseHandler { return &connWithCloseHandler{conn, onClose} } @@ -62,10 +64,10 @@ func (conn *connWithCloseHandler) Close() error { type packetConnWithDownloadBandwidthLimiter struct { net.PacketConn ctx context.Context - limiter Limiter + limiter BandwidthLimiter } -func NewPacketConnWithDownloadBandwidthLimiter(ctx context.Context, conn net.PacketConn, limiter Limiter) *packetConnWithDownloadBandwidthLimiter { +func NewPacketConnWithDownloadBandwidthLimiter(ctx context.Context, conn net.PacketConn, limiter BandwidthLimiter) *packetConnWithDownloadBandwidthLimiter { return &packetConnWithDownloadBandwidthLimiter{conn, ctx, limiter} } @@ -80,10 +82,10 @@ func (conn *packetConnWithDownloadBandwidthLimiter) WriteTo(p []byte, addr net.A type packetConnWithUploadBandwidthLimiter struct { net.PacketConn ctx context.Context - limiter Limiter + limiter BandwidthLimiter } -func NewPacketConnWithUploadBandwidthLimiter(ctx context.Context, conn net.PacketConn, limiter Limiter) *packetConnWithUploadBandwidthLimiter { +func NewPacketConnWithUploadBandwidthLimiter(ctx context.Context, conn net.PacketConn, limiter BandwidthLimiter) *packetConnWithUploadBandwidthLimiter { return &packetConnWithUploadBandwidthLimiter{conn, ctx, limiter} } @@ -101,10 +103,10 @@ func (conn *packetConnWithUploadBandwidthLimiter) ReadFrom(p []byte) (n int, add type packetConnWithCloseHandler struct { net.PacketConn - onClose CloseHandlerFunc + onClose onclose.CloseHandlerFunc } -func NewPacketConnWithCloseHandler(conn net.PacketConn, onClose CloseHandlerFunc) *packetConnWithCloseHandler { +func NewPacketConnWithCloseHandler(conn net.PacketConn, onClose onclose.CloseHandlerFunc) *packetConnWithCloseHandler { return &packetConnWithCloseHandler{conn, onClose} } @@ -113,38 +115,38 @@ func (conn *packetConnWithCloseHandler) Close() error { return conn.PacketConn.Close() } -func connWithDownloadBandwidthWrapper(ctx context.Context, conn net.Conn, limiter Limiter, reverse bool) net.Conn { +func connWithDownloadBandwidthWrapper(ctx context.Context, conn net.Conn, limiter BandwidthLimiter, reverse bool) net.Conn { if reverse { return NewConnWithUploadBandwidthLimiter(ctx, conn, limiter) } return NewConnWithDownloadBandwidthLimiter(ctx, conn, limiter) } -func connWithUploadBandwidthWrapper(ctx context.Context, conn net.Conn, limiter Limiter, reverse bool) net.Conn { +func connWithUploadBandwidthWrapper(ctx context.Context, conn net.Conn, limiter BandwidthLimiter, reverse bool) net.Conn { if reverse { return NewConnWithDownloadBandwidthLimiter(ctx, conn, limiter) } return NewConnWithUploadBandwidthLimiter(ctx, conn, limiter) } -func connWithBidirectionalBandwidthWrapper(ctx context.Context, conn net.Conn, limiter Limiter, reverse bool) net.Conn { +func connWithBidirectionalBandwidthWrapper(ctx context.Context, conn net.Conn, limiter BandwidthLimiter, reverse bool) net.Conn { return NewConnWithUploadBandwidthLimiter(ctx, NewConnWithDownloadBandwidthLimiter(ctx, conn, limiter), limiter) } -func packetConnWithDownloadBandwidthWrapper(ctx context.Context, conn net.PacketConn, limiter Limiter, reverse bool) net.PacketConn { +func packetConnWithDownloadBandwidthWrapper(ctx context.Context, conn net.PacketConn, limiter BandwidthLimiter, reverse bool) net.PacketConn { if reverse { return NewPacketConnWithUploadBandwidthLimiter(ctx, conn, limiter) } return NewPacketConnWithDownloadBandwidthLimiter(ctx, conn, limiter) } -func packetConnWithUploadBandwidthWrapper(ctx context.Context, conn net.PacketConn, limiter Limiter, reverse bool) net.PacketConn { +func packetConnWithUploadBandwidthWrapper(ctx context.Context, conn net.PacketConn, limiter BandwidthLimiter, reverse bool) net.PacketConn { if reverse { return NewPacketConnWithDownloadBandwidthLimiter(ctx, conn, limiter) } return NewPacketConnWithUploadBandwidthLimiter(ctx, conn, limiter) } -func packetConnWithBidirectionalBandwidthWrapper(ctx context.Context, conn net.PacketConn, limiter Limiter, reverse bool) net.PacketConn { +func packetConnWithBidirectionalBandwidthWrapper(ctx context.Context, conn net.PacketConn, limiter BandwidthLimiter, reverse bool) net.PacketConn { return NewPacketConnWithUploadBandwidthLimiter(ctx, NewPacketConnWithDownloadBandwidthLimiter(ctx, conn, limiter), limiter) } diff --git a/protocol/limiter/bandwidth/limiter.go b/protocol/limiter/bandwidth/limiter.go index 15ed5c42..07ff57e0 100644 --- a/protocol/limiter/bandwidth/limiter.go +++ b/protocol/limiter/bandwidth/limiter.go @@ -9,12 +9,13 @@ import ( "github.com/sagernet/sing-box/adapter" ) -type Limiter interface { +type BandwidthLimiter interface { WaitN(ctx context.Context, n int) (err error) + SetSpeed(speed uint64) } type FlowKeysLimiter struct { - limiter Limiter + limiter BandwidthLimiter connIDGetter ConnIDGetter waits map[string][]*wait @@ -25,7 +26,7 @@ type FlowKeysLimiter struct { mtx sync.Mutex } -func NewFlowKeysLimiter(connIDGetter ConnIDGetter, limiter Limiter) *FlowKeysLimiter { +func NewFlowKeysLimiter(connIDGetter ConnIDGetter, limiter BandwidthLimiter) *FlowKeysLimiter { return &FlowKeysLimiter{ limiter: limiter, connIDGetter: connIDGetter, @@ -36,6 +37,10 @@ func NewFlowKeysLimiter(connIDGetter ConnIDGetter, limiter Limiter) *FlowKeysLim } } +func (l *FlowKeysLimiter) SetSpeed(speed uint64) { + l.limiter.SetSpeed(speed) +} + func (l *FlowKeysLimiter) WaitN(ctx context.Context, n int) error { id, _ := l.connIDGetter(ctx, adapter.ContextFrom(ctx)) mainWait := &wait{ctx, make(chan struct{}), n} diff --git a/protocol/limiter/bandwidth/strategy.go b/protocol/limiter/bandwidth/strategy.go index 14061ee8..1dbb0f14 100644 --- a/protocol/limiter/bandwidth/strategy.go +++ b/protocol/limiter/bandwidth/strategy.go @@ -7,16 +7,16 @@ import ( "sync" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/onclose" "github.com/sagernet/sing-box/log" E "github.com/sagernet/sing/common/exceptions" "golang.org/x/time/rate" ) type ( - CloseHandlerFunc = func() ConnIDGetter = func(context.Context, *adapter.InboundContext) (string, bool) - ConnWrapper = func(ctx context.Context, conn net.Conn, limiter Limiter, reverse bool) net.Conn - PacketConnWrapper = func(ctx context.Context, conn net.PacketConn, limiter Limiter, reverse bool) net.PacketConn + ConnWrapper = func(ctx context.Context, conn net.Conn, limiter BandwidthLimiter, reverse bool) net.Conn + PacketConnWrapper = func(ctx context.Context, conn net.PacketConn, limiter BandwidthLimiter, reverse bool) net.PacketConn ) type BandwidthStrategy interface { @@ -24,8 +24,12 @@ type BandwidthStrategy interface { wrapPacketConn(ctx context.Context, conn net.PacketConn, metadata *adapter.InboundContext, reverse bool) (net.PacketConn, error) } +type SpeedUpdater interface { + SetSpeed(speed uint64) +} + type BandwidthLimiterStrategy interface { - getLimiter(ctx context.Context, metadata *adapter.InboundContext) (Limiter, CloseHandlerFunc, error) + getLimiter(ctx context.Context, metadata *adapter.InboundContext) (BandwidthLimiter, onclose.CloseHandlerFunc, error) } type DefaultWrapStrategy struct { @@ -54,8 +58,14 @@ func (s *DefaultWrapStrategy) wrapPacketConn(ctx context.Context, conn net.Packe return NewPacketConnWithCloseHandler(s.packetConnWrapper(ctx, conn, limiter, reverse), onClose), nil } +func (s *DefaultWrapStrategy) SetSpeed(speed uint64) { + if updater, ok := s.limiterStrategy.(SpeedUpdater); ok { + updater.SetSpeed(speed) + } +} + type GlobalBandwidthStrategy struct { - limiter Limiter + limiter BandwidthLimiter } func NewGlobalBandwidthStrategy(speed uint64, flowKeys []string) (*GlobalBandwidthStrategy, error) { @@ -68,12 +78,16 @@ func NewGlobalBandwidthStrategy(speed uint64, flowKeys []string) (*GlobalBandwid }, nil } -func (s *GlobalBandwidthStrategy) getLimiter(ctx context.Context, metadata *adapter.InboundContext) (Limiter, CloseHandlerFunc, error) { +func (s *GlobalBandwidthStrategy) getLimiter(ctx context.Context, metadata *adapter.InboundContext) (BandwidthLimiter, onclose.CloseHandlerFunc, error) { return s.limiter, func() {}, nil } +func (s *GlobalBandwidthStrategy) SetSpeed(speed uint64) { + s.limiter.SetSpeed(speed) +} + type idBandwidthLimiter struct { - limiter Limiter + limiter BandwidthLimiter handles uint32 } @@ -94,7 +108,7 @@ func NewConnectionBandwidthStrategy(connIDGetter ConnIDGetter, speed uint64, flo } } -func (s *ConnectionBandwidthStrategy) getLimiter(ctx context.Context, metadata *adapter.InboundContext) (Limiter, CloseHandlerFunc, error) { +func (s *ConnectionBandwidthStrategy) getLimiter(ctx context.Context, metadata *adapter.InboundContext) (BandwidthLimiter, onclose.CloseHandlerFunc, error) { s.mtx.Lock() defer s.mtx.Unlock() id, ok := s.connIDGetter(ctx, metadata) @@ -126,6 +140,15 @@ func (s *ConnectionBandwidthStrategy) getLimiter(ctx context.Context, metadata * }, nil } +func (s *ConnectionBandwidthStrategy) SetSpeed(speed uint64) { + s.mtx.Lock() + defer s.mtx.Unlock() + s.speed = speed + for _, limiter := range s.limiters { + limiter.limiter.SetSpeed(speed) + } +} + type UsersBandwidthStrategy struct { strategies map[string]BandwidthStrategy mtx sync.Mutex @@ -167,20 +190,86 @@ func (s *UsersBandwidthStrategy) getStrategy(ctx context.Context, metadata *adap return nil, E.New("user strategy not found: ", user) } +type bwConnEntry struct { + conn net.Conn +} + + + type ManagerBandwidthStrategy struct { - *UsersBandwidthStrategy + strategies map[string]BandwidthStrategy + conns map[string][]*bwConnEntry + + mtx sync.Mutex } func NewManagerBandwidthStrategy() *ManagerBandwidthStrategy { return &ManagerBandwidthStrategy{ - UsersBandwidthStrategy: NewUsersBandwidthStrategy(map[string]BandwidthStrategy{}), + strategies: make(map[string]BandwidthStrategy), + conns: make(map[string][]*bwConnEntry), } } +func (s *ManagerBandwidthStrategy) wrapConn(ctx context.Context, conn net.Conn, metadata *adapter.InboundContext, reverse bool) (net.Conn, error) { + s.mtx.Lock() + var user string + if metadata != nil { + user = metadata.User + } + strategy, ok := s.strategies[user] + s.mtx.Unlock() + if !ok { + return nil, E.New("user strategy not found: ", user) + } + wrapped, err := strategy.wrapConn(ctx, conn, metadata, reverse) + if err != nil { + return nil, err + } + entry := &bwConnEntry{conn: conn} + s.mtx.Lock() + s.conns[user] = append(s.conns[user], entry) + s.mtx.Unlock() + return onclose.NewConn(wrapped, func() { + s.mtx.Lock() + entries := s.conns[user] + for i, e := range entries { + if e == entry { + s.conns[user] = append(entries[:i], entries[i+1:]...) + break + } + } + s.mtx.Unlock() + }), nil +} + +func (s *ManagerBandwidthStrategy) wrapPacketConn(ctx context.Context, conn net.PacketConn, metadata *adapter.InboundContext, reverse bool) (net.PacketConn, error) { + s.mtx.Lock() + var user string + if metadata != nil { + user = metadata.User + } + strategy, ok := s.strategies[user] + s.mtx.Unlock() + if !ok { + return nil, E.New("user strategy not found: ", user) + } + return strategy.wrapPacketConn(ctx, conn, metadata, reverse) +} + func (s *ManagerBandwidthStrategy) UpdateStrategies(strategies map[string]BandwidthStrategy) { s.mtx.Lock() - defer s.mtx.Unlock() + var closedEntries []*bwConnEntry + for user, entries := range s.conns { + if _, exists := strategies[user]; !exists { + closedEntries = append(closedEntries, entries...) + delete(s.conns, user) + } + } s.strategies = strategies + s.mtx.Unlock() + for _, entry := range closedEntries { + entry.conn.Close() + } } type BypassBandwidthStrategy struct{} @@ -263,8 +352,8 @@ func CreateStrategy(strategy string, mode string, connectionType string, speed u return NewDefaultWrapStrategy(limiterStrategy, connWrapper, packetConnWrapper), nil } -func createSpeedLimiter(speed uint64, flowKeys []string) (Limiter, error) { - var limiter Limiter = rate.NewLimiter(rate.Limit(float64(speed)), 65536) +func createSpeedLimiter(speed uint64, flowKeys []string) (BandwidthLimiter, error) { + var limiter BandwidthLimiter = &speedLimiter{limiter: rate.NewLimiter(rate.Limit(float64(speed)), 65536)} for i := len(flowKeys) - 1; i >= 0; i-- { getter, err := flowKeysConnIDGetter(flowKeys[i]) if err != nil { @@ -275,16 +364,24 @@ func createSpeedLimiter(speed uint64, flowKeys []string) (Limiter, error) { return limiter, nil } +type speedLimiter struct { + limiter *rate.Limiter +} + +func (r *speedLimiter) WaitN(ctx context.Context, n int) error { + return r.limiter.WaitN(ctx, n) +} + +func (r *speedLimiter) SetSpeed(speed uint64) { + r.limiter.SetLimit(rate.Limit(float64(speed))) +} + func flowKeysConnIDGetter(name string) (ConnIDGetter, error) { switch name { case "user": return func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { return metadata.User, true }, nil - case "destination": - return func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { - return metadata.Destination.String(), true - }, nil case "source_ip": return func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { return metadata.Source.IPAddr().String(), true @@ -302,6 +399,14 @@ func flowKeysConnIDGetter(name string) (ConnIDGetter, error) { } return strconv.FormatUint(uint64(id.ID), 10), ok }, nil + case "protocol": + return func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + return metadata.Protocol, metadata.Protocol != "" + }, nil + case "destination": + return func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + return metadata.Destination.String(), true + }, nil default: return nil, E.New("flow key not found: ", name) } diff --git a/protocol/limiter/connection/lock.go b/protocol/limiter/connection/lock.go index c646c0b4..247717b9 100644 --- a/protocol/limiter/connection/lock.go +++ b/protocol/limiter/connection/lock.go @@ -4,13 +4,14 @@ import ( "context" "sync" + "github.com/sagernet/sing-box/common/onclose" E "github.com/sagernet/sing/common/exceptions" ) func NewDefaultLock(max uint32) LockIDGetter { locks := make(map[string]*uint32) mtx := sync.Mutex{} - return func(id string) (CloseHandlerFunc, context.Context, error) { + return func(id string) (onclose.CloseHandlerFunc, context.Context, error) { mtx.Lock() defer mtx.Unlock() handles, ok := locks[id] @@ -22,16 +23,13 @@ func NewDefaultLock(max uint32) LockIDGetter { locks[id] = handles } *handles++ - var once sync.Once return func() { - once.Do(func() { - mtx.Lock() - defer mtx.Unlock() - *handles-- - if *handles == 0 { - delete(locks, id) - } - }) + mtx.Lock() + defer mtx.Unlock() + *handles-- + if *handles == 0 { + delete(locks, id) + } }, nil, nil } } diff --git a/protocol/limiter/connection/outbound.go b/protocol/limiter/connection/outbound.go index 722a7ea0..384a5966 100644 --- a/protocol/limiter/connection/outbound.go +++ b/protocol/limiter/connection/outbound.go @@ -7,6 +7,7 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/outbound" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/common/onclose" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/route" @@ -110,7 +111,7 @@ func (h *Outbound) DialContext(ctx context.Context, network string, destination onClose() return nil, err } - conn = newConnWithCloseHandlerFunc(conn, onClose) + conn = onclose.NewConn(conn, onClose) if lockCtx != nil { go connChecker(lockCtx, conn.Close) } @@ -127,7 +128,7 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n onClose() return nil, err } - conn = newPacketConnWithCloseHandlerFunc(conn, onClose) + conn = onclose.NewPacketConn(conn, onClose) if lockCtx != nil { go connChecker(lockCtx, conn.Close) } @@ -141,7 +142,7 @@ func (h *Outbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata N.CloseOnHandshakeFailure(conn, onClose, err) return } - conn = newConnWithCloseHandlerFunc(conn, limiterOnClose) + conn = onclose.NewConn(conn, limiterOnClose) if lockCtx != nil { go connChecker(lockCtx, conn.Close) } @@ -158,7 +159,7 @@ func (h *Outbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, N.CloseOnHandshakeFailure(conn, onClose, err) return } - conn = bufio.NewPacketConn(newPacketConnWithCloseHandlerFunc(bufio.NewNetPacketConn(conn), limiterOnClose)) + conn = bufio.NewPacketConn(onclose.NewPacketConn(bufio.NewNetPacketConn(conn), limiterOnClose)) if lockCtx != nil { go connChecker(lockCtx, conn.Close) } @@ -172,33 +173,7 @@ func (h *Outbound) GetStrategy() ConnectionStrategy { return h.strategy } -type connWithCloseHandlerFunc struct { - net.Conn - onClose CloseHandlerFunc -} -func newConnWithCloseHandlerFunc(conn net.Conn, onClose CloseHandlerFunc) *connWithCloseHandlerFunc { - return &connWithCloseHandlerFunc{conn, onClose} -} - -func (conn *connWithCloseHandlerFunc) Close() error { - conn.onClose() - return conn.Conn.Close() -} - -type packetConnWithCloseHandlerFunc struct { - net.PacketConn - onClose CloseHandlerFunc -} - -func newPacketConnWithCloseHandlerFunc(conn net.PacketConn, onClose CloseHandlerFunc) *packetConnWithCloseHandlerFunc { - return &packetConnWithCloseHandlerFunc{conn, onClose} -} - -func (conn *packetConnWithCloseHandlerFunc) Close() error { - conn.onClose() - return conn.PacketConn.Close() -} func connChecker(ctx context.Context, closeFunc func() error) { <-ctx.Done() diff --git a/protocol/limiter/connection/strategy.go b/protocol/limiter/connection/strategy.go index b32d19e3..68425981 100644 --- a/protocol/limiter/connection/strategy.go +++ b/protocol/limiter/connection/strategy.go @@ -6,18 +6,17 @@ import ( "sync" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/onclose" "github.com/sagernet/sing-box/log" E "github.com/sagernet/sing/common/exceptions" ) type ( - CloseHandlerFunc = func() - ConnIDGetter = func(context.Context, *adapter.InboundContext) (string, bool) - LockIDGetter = func(string) (CloseHandlerFunc, context.Context, error) + LockIDGetter = func(string) (onclose.CloseHandlerFunc, context.Context, error) ConnectionStrategy interface { - request(ctx context.Context, metadata *adapter.InboundContext) (onClose CloseHandlerFunc, lockCtx context.Context, err error) + request(ctx context.Context, metadata *adapter.InboundContext) (onClose onclose.CloseHandlerFunc, lockCtx context.Context, err error) } ) @@ -36,7 +35,7 @@ func NewDefaultConnectionStrategy(connIDGetter ConnIDGetter, lockIDGetter LockID return outbound } -func (s *DefaultConnectionStrategy) request(ctx context.Context, metadata *adapter.InboundContext) (CloseHandlerFunc, context.Context, error) { +func (s *DefaultConnectionStrategy) request(ctx context.Context, metadata *adapter.InboundContext) (onclose.CloseHandlerFunc, context.Context, error) { s.mtx.Lock() defer s.mtx.Unlock() id, ok := s.connIDGetter(ctx, metadata) @@ -57,7 +56,7 @@ func NewUsersConnectionStrategy(strategies map[string]ConnectionStrategy) *Users } } -func (s *UsersConnectionStrategy) request(ctx context.Context, metadata *adapter.InboundContext) (CloseHandlerFunc, context.Context, error) { +func (s *UsersConnectionStrategy) request(ctx context.Context, metadata *adapter.InboundContext) (onclose.CloseHandlerFunc, context.Context, error) { s.mtx.Lock() defer s.mtx.Unlock() var user string @@ -71,20 +70,78 @@ func (s *UsersConnectionStrategy) request(ctx context.Context, metadata *adapter return nil, nil, E.New("user strategy not found: ", user) } +type cancelEntry struct { + cancel context.CancelFunc +} + type ManagerConnectionStrategy struct { - *UsersConnectionStrategy + strategies map[string]ConnectionStrategy + cancels map[string][]*cancelEntry + + mtx sync.Mutex } func NewManagerConnectionStrategy() *ManagerConnectionStrategy { return &ManagerConnectionStrategy{ - UsersConnectionStrategy: NewUsersConnectionStrategy(map[string]ConnectionStrategy{}), + strategies: make(map[string]ConnectionStrategy), + cancels: make(map[string][]*cancelEntry), } } +func (s *ManagerConnectionStrategy) request(ctx context.Context, metadata *adapter.InboundContext) (onclose.CloseHandlerFunc, context.Context, error) { + s.mtx.Lock() + var user string + if metadata != nil { + user = metadata.User + } + strategy, ok := s.strategies[user] + if !ok { + s.mtx.Unlock() + return nil, nil, E.New("user strategy not found: ", user) + } + s.mtx.Unlock() + onClose, _, err := strategy.request(ctx, metadata) + if err != nil { + return nil, nil, err + } + cancelCtx, cancel := context.WithCancel(context.Background()) + entry := &cancelEntry{cancel: cancel} + s.mtx.Lock() + s.cancels[user] = append(s.cancels[user], entry) + s.mtx.Unlock() + originalOnClose := onClose + wrappedOnClose := func() { + s.mtx.Lock() + entries := s.cancels[user] + for i, e := range entries { + if e == entry { + s.cancels[user] = append(entries[:i], entries[i+1:]...) + break + } + } + s.mtx.Unlock() + cancel() + if originalOnClose != nil { + originalOnClose() + } + } + return wrappedOnClose, cancelCtx, nil +} + func (s *ManagerConnectionStrategy) UpdateStrategies(strategies map[string]ConnectionStrategy) { s.mtx.Lock() - defer s.mtx.Unlock() + var entries []*cancelEntry + for user, cancels := range s.cancels { + if _, exists := strategies[user]; !exists { + entries = append(entries, cancels...) + delete(s.cancels, user) + } + } s.strategies = strategies + s.mtx.Unlock() + for _, entry := range entries { + entry.cancel() + } } type BypassConnectionStrategy struct{} @@ -93,7 +150,7 @@ func NewBypassConnectionStrategy() *BypassConnectionStrategy { return &BypassConnectionStrategy{} } -func (s *BypassConnectionStrategy) request(ctx context.Context, metadata *adapter.InboundContext) (CloseHandlerFunc, context.Context, error) { +func (s *BypassConnectionStrategy) request(ctx context.Context, metadata *adapter.InboundContext) (onclose.CloseHandlerFunc, context.Context, error) { return func() {}, nil, nil } diff --git a/protocol/limiter/traffic/strategy.go b/protocol/limiter/traffic/strategy.go index 64cc5275..2c082a68 100644 --- a/protocol/limiter/traffic/strategy.go +++ b/protocol/limiter/traffic/strategy.go @@ -6,11 +6,11 @@ import ( "sync" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/onclose" E "github.com/sagernet/sing/common/exceptions" ) type ( - CloseHandlerFunc = func() ConnWrapper = func(ctx context.Context, conn net.Conn, limiter TrafficLimiter, reverse bool) net.Conn PacketConnWrapper = func(ctx context.Context, conn net.PacketConn, limiter TrafficLimiter, reverse bool) net.PacketConn ) @@ -72,32 +72,60 @@ func (s *GlobalTrafficStrategy) getLimiter(ctx context.Context, metadata *adapte return s.limiter, nil } +type connEntry struct { + conn net.Conn +} + + + type ManagerTrafficStrategy struct { strategies map[string]TrafficStrategy - mtx sync.Mutex + conns map[string][]*connEntry + + mtx sync.Mutex } func NewManagerTrafficStrategy() *ManagerTrafficStrategy { - return &ManagerTrafficStrategy{} + return &ManagerTrafficStrategy{ + conns: make(map[string][]*connEntry), + } } func (s *ManagerTrafficStrategy) wrapConn(ctx context.Context, conn net.Conn, metadata *adapter.InboundContext, reverse bool) (net.Conn, error) { - strategy, err := s.getStrategy(ctx, metadata) + strategy, user, err := s.getStrategy(ctx, metadata) if err != nil { return nil, err } - return strategy.wrapConn(ctx, conn, metadata, reverse) + wrapped, err := strategy.wrapConn(ctx, conn, metadata, reverse) + if err != nil { + return nil, err + } + entry := &connEntry{conn: conn} + s.mtx.Lock() + s.conns[user] = append(s.conns[user], entry) + s.mtx.Unlock() + return onclose.NewConn(wrapped, func() { + s.mtx.Lock() + entries := s.conns[user] + for i, e := range entries { + if e == entry { + s.conns[user] = append(entries[:i], entries[i+1:]...) + break + } + } + s.mtx.Unlock() + }), nil } func (s *ManagerTrafficStrategy) wrapPacketConn(ctx context.Context, conn net.PacketConn, metadata *adapter.InboundContext, reverse bool) (net.PacketConn, error) { - strategy, err := s.getStrategy(ctx, metadata) + strategy, _, err := s.getStrategy(ctx, metadata) if err != nil { return nil, err } return strategy.wrapPacketConn(ctx, conn, metadata, reverse) } -func (s *ManagerTrafficStrategy) getStrategy(ctx context.Context, metadata *adapter.InboundContext) (TrafficStrategy, error) { +func (s *ManagerTrafficStrategy) getStrategy(ctx context.Context, metadata *adapter.InboundContext) (TrafficStrategy, string, error) { s.mtx.Lock() defer s.mtx.Unlock() var user string @@ -106,15 +134,25 @@ func (s *ManagerTrafficStrategy) getStrategy(ctx context.Context, metadata *adap } strategy, ok := s.strategies[user] if ok { - return strategy, nil + return strategy, user, nil } - return nil, E.New("user strategy not found: ", user) + return nil, user, E.New("user strategy not found: ", user) } func (s *ManagerTrafficStrategy) UpdateStrategies(strategies map[string]TrafficStrategy) { s.mtx.Lock() - defer s.mtx.Unlock() + var closedEntries []*connEntry + for user, entries := range s.conns { + if _, exists := strategies[user]; !exists { + closedEntries = append(closedEntries, entries...) + delete(s.conns, user) + } + } s.strategies = strategies + s.mtx.Unlock() + for _, entry := range closedEntries { + entry.conn.Close() + } } type BypassTrafficStrategy struct{} diff --git a/protocol/masque/outbound.go b/protocol/masque/outbound.go index 55641bf7..1d7dd5bf 100644 --- a/protocol/masque/outbound.go +++ b/protocol/masque/outbound.go @@ -9,6 +9,7 @@ import ( "time" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common" "github.com/sagernet/sing-box/adapter/outbound" "github.com/sagernet/sing-box/common/cloudflare" "github.com/sagernet/sing-box/common/dialer" @@ -99,7 +100,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL logger.ErrorContext(ctx, E.New("failed to generate cert: ", err)) return } - tlsConfig, err := tls.NewMASQUEClient(ctx, logger, "consumer-masque.cloudflareclient.com", cert, privKey, peerPubKey, options.MASQUEOutboundTLSOptions) + tlsConfig, err := tls.NewMASQUEClient(ctx, logger, "consumer-masque.cloudflareclient.com", cert, privKey, peerPubKey, common.PtrValueOrDefault(options.TLS)) if err != nil { logger.ErrorContext(ctx, E.New("failed to prepare TLS config: ", err)) return diff --git a/protocol/mixed/inbound.go b/protocol/mixed/inbound.go index 64c3edb5..2a2befcf 100644 --- a/protocol/mixed/inbound.go +++ b/protocol/mixed/inbound.go @@ -98,6 +98,10 @@ func (h *Inbound) Close() error { ) } +func (h *Inbound) UpdateUsers(users []auth.User) { + h.authenticator.UpdateUsers(users) +} + func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { err := h.newConnection(ctx, conn, metadata, onClose) N.CloseOnHandshakeFailure(conn, onClose, err) diff --git a/protocol/naive/inbound.go b/protocol/naive/inbound.go index 41f41798..d15d5145 100644 --- a/protocol/naive/inbound.go +++ b/protocol/naive/inbound.go @@ -147,6 +147,10 @@ func (n *Inbound) Close() error { ) } +func (n *Inbound) UpdateUsers(users []auth.User) { + n.authenticator.UpdateUsers(users) +} + func (n *Inbound) ServeHTTP(writer http.ResponseWriter, request *http.Request) { ctx := log.ContextWithNewID(request.Context()) if request.Method != "CONNECT" { diff --git a/protocol/openvpn/outbound.go b/protocol/openvpn/outbound.go new file mode 100644 index 00000000..5b645181 --- /dev/null +++ b/protocol/openvpn/outbound.go @@ -0,0 +1,158 @@ +//go:build with_openvpn + +package openvpn + +import ( + "context" + "os" + "time" + "net" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + ovpn "github.com/sagernet/sing-box/transport/openvpn" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.OpenVPNOutboundOptions](registry, C.TypeOpenVPN, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + ctx context.Context + dnsRouter adapter.DNSRouter + logger logger.ContextLogger + tunnel *ovpn.Tunnel +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.OpenVPNOutboundOptions) (adapter.Outbound, error) { + tlsConfig, err := tls.NewOpenVPNClient(ctx, logger, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + var tlsKey []byte + keyDirection := -1 + if options.TLSAuth != "" || options.TLSAuthPath != "" { + tlsAuth := options.TLSAuth + if tlsAuth == "" { + data, err := os.ReadFile(options.TLSAuthPath) + if err != nil { + return nil, E.Cause(err, "read tls_auth_path") + } + tlsAuth = string(data) + } + tlsKey = []byte(tlsAuth) + keyDirection = options.KeyDirection + } else { + tlsCrypt := options.TLSCrypt + if tlsCrypt == "" && options.TLSCryptPath != "" { + data, err := os.ReadFile(options.TLSCryptPath) + if err != nil { + return nil, E.Cause(err, "read tls_crypt_path") + } + tlsCrypt = string(data) + } + tlsKey = []byte(tlsCrypt) + } + clientConfig := &ovpn.ClientConfig{ + Proto: options.Proto, + Cipher: options.Cipher, + Auth: options.Auth, + Username: options.Username, + Password: options.Password, + KeyDirection: keyDirection, + TLSCrypt: tlsKey, + TLSCryptV2: options.TLSCryptV2, + } + if err := clientConfig.Prepare(); err != nil { + return nil, E.Cause(err, "invalid openvpn config") + } + outboundDialer, err := dialer.New(ctx, options.DialerOptions, true) + if err != nil { + return nil, err + } + o := &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeOpenVPN, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.DialerOptions), + ctx: ctx, + dnsRouter: service.FromContext[adapter.DNSRouter](ctx), + logger: logger, + } + tunnel, err := ovpn.NewTunnel(ctx, logger, ovpn.TunnelOptions{ + Dialer: outboundDialer, + Servers: options.Servers, + TLSConfig: tlsConfig, + Config: clientConfig, + ReconnectDelay: time.Duration(options.ReconnectDelay), + PingInterval: time.Duration(options.PingInterval), + }) + if err != nil { + return nil, err + } + o.tunnel = tunnel + return o, nil +} + +func (o *Outbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStatePostStart { + return nil + } + return o.tunnel.Start() +} + +func (o *Outbound) Close() error { + if o.tunnel != nil { + return o.tunnel.Close() + } + return nil +} + +func (o *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch network { + case N.NetworkTCP: + o.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + o.logger.InfoContext(ctx, "outbound packet connection to ", destination) + } + if destination.IsDomain() { + destinationAddresses, err := o.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, err + } + return N.DialSerial(ctx, o.tunnel, network, destination, destinationAddresses) + } + if !destination.Addr.IsValid() { + return nil, E.New("invalid destination: ", destination) + } + return o.tunnel.DialContext(ctx, network, destination) +} + +func (o *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + o.logger.InfoContext(ctx, "outbound packet connection to ", destination) + if destination.IsDomain() { + destinationAddresses, err := o.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, err + } + packetConn, destinationAddress, err := N.ListenSerial(ctx, o.tunnel, destination, destinationAddresses) + if err != nil { + return nil, err + } + if destinationAddress.IsValid() && destination != M.SocksaddrFrom(destinationAddress, destination.Port) { + return bufio.NewNATPacketConn(bufio.NewPacketConn(packetConn), M.SocksaddrFrom(destinationAddress, destination.Port), destination), nil + } + return packetConn, nil + } + return o.tunnel.ListenPacket(ctx, destination) +} diff --git a/protocol/socks/inbound.go b/protocol/socks/inbound.go index 68e0ef58..56fd31ef 100644 --- a/protocol/socks/inbound.go +++ b/protocol/socks/inbound.go @@ -70,6 +70,10 @@ func (h *Inbound) Close() error { return h.listener.Close() } +func (h *Inbound) UpdateUsers(users []auth.User) { + h.authenticator.UpdateUsers(users) +} + func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { err := socks.HandleConnectionEx(ctx, conn, std_bufio.NewReader(conn), h.authenticator, adapter.NewUpstreamHandlerEx(metadata, h.newUserConnection, h.streamUserPacketConnection), h.listener, h.udpTimeout, metadata.Source, onClose) N.CloseOnHandshakeFailure(conn, onClose, err) diff --git a/protocol/sudoku/inbound.go b/protocol/sudoku/inbound.go new file mode 100644 index 00000000..89090e6c --- /dev/null +++ b/protocol/sudoku/inbound.go @@ -0,0 +1,178 @@ +package sudoku + +import ( + "context" + "net" + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/sudoku" + "github.com/sagernet/sing/common/bufio" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.SudokuInboundOptions](registry, C.TypeSudoku, NewInbound) +} + +type Inbound struct { + inbound.Adapter + router adapter.ConnectionRouterEx + logger logger.ContextLogger + listener *listener.Listener + protoConf sudoku.ProtocolConfig + tunnelSrv *sudoku.HTTPMaskTunnelServer + fallback string +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SudokuInboundOptions) (adapter.Inbound, error) { + defaultConf := sudoku.DefaultConfig() + tableType, err := sudoku.NormalizeTableType(options.TableType) + if err != nil { + return nil, err + } + paddingMin, paddingMax := sudoku.ResolvePadding(options.PaddingMin, options.PaddingMax, defaultConf.PaddingMin, defaultConf.PaddingMax) + enablePureDownlink := sudoku.DerefBool(options.EnablePureDownlink, defaultConf.EnablePureDownlink) + handshakeTimeout := sudoku.DerefInt(options.HandshakeTimeout, defaultConf.HandshakeTimeoutSeconds) + + tables, err := sudoku.NewServerTablesWithCustomPatterns(sudoku.ServerAEADSeed(options.Key), tableType, options.CustomTable, options.CustomTables) + if err != nil { + return nil, err + } + + protoConf := sudoku.ProtocolConfig{ + Key: options.Key, + AEADMethod: defaultConf.AEADMethod, + PaddingMin: paddingMin, + PaddingMax: paddingMax, + EnablePureDownlink: enablePureDownlink, + HandshakeTimeoutSeconds: handshakeTimeout, + DisableHTTPMask: options.DisableHTTPMask, + HTTPMaskMode: options.HTTPMaskMode, + HTTPMaskPathRoot: strings.TrimSpace(options.PathRoot), + } + if len(tables) == 1 { + protoConf.Table = tables[0] + } else { + protoConf.Tables = tables + } + if options.AEADMethod != "" { + protoConf.AEADMethod = options.AEADMethod + } + + in := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeSudoku, tag), + router: router, + logger: logger, + protoConf: protoConf, + fallback: strings.TrimSpace(options.Fallback), + } + if in.fallback != "" { + in.tunnelSrv = sudoku.NewHTTPMaskTunnelServerWithFallback(&in.protoConf) + } else { + in.tunnelSrv = sudoku.NewHTTPMaskTunnelServer(&in.protoConf) + } + + in.listener = listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkTCP}, + Listen: options.ListenOptions, + ConnectionHandler: in, + }) + return in, nil +} + +func (h *Inbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + return h.listener.Start() +} + +func (h *Inbound) Close() error { + return h.listener.Close() +} + +func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + h.handleConn(ctx, conn, metadata, onClose) +} + +func (h *Inbound) handleConn(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + handshakeConn := conn + handshakeCfg := &h.protoConf + + if h.tunnelSrv != nil { + c, cfg, done, err := h.tunnelSrv.WrapConn(conn) + if err != nil { + conn.Close() + return + } + if done { + return + } + if c != nil { + handshakeConn = c + } + if cfg != nil { + handshakeCfg = cfg + } + } + + cConn, meta, err := sudoku.ServerHandshake(handshakeConn, handshakeCfg) + if err != nil { + h.logger.DebugContext(ctx, "handshake failed: ", err) + conn.Close() + if handshakeConn != conn { + handshakeConn.Close() + } + return + } + + session, err := sudoku.ReadServerSession(cConn, meta) + if err != nil { + h.logger.WarnContext(ctx, "read session failed: ", err) + cConn.Close() + return + } + + switch session.Type { + case sudoku.SessionTypeUoT: + h.handleUoT(ctx, session.Conn, metadata, onClose) + case sudoku.SessionTypeMultiplex: + mux, err := sudoku.AcceptMultiplexServer(session.Conn) + if err != nil { + session.Conn.Close() + return + } + defer mux.Close() + for { + stream, target, err := mux.AcceptTCP() + if err != nil { + return + } + go h.routeTCP(ctx, stream, target, metadata) + } + default: + h.routeTCP(ctx, session.Conn, session.Target, metadata) + } +} + +func (h *Inbound) routeTCP(ctx context.Context, conn net.Conn, target string, metadata adapter.InboundContext) { + destination := M.ParseSocksaddr(target) + metadata.Destination = destination + h.logger.InfoContext(ctx, "inbound connection to ", destination) + h.router.RouteConnectionEx(ctx, conn, metadata, nil) +} + +func (h *Inbound) handleUoT(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + packetConn := sudoku.NewUoTPacketConn(conn) + h.router.RoutePacketConnectionEx(ctx, bufio.NewPacketConn(packetConn), metadata, onClose) +} diff --git a/protocol/sudoku/outbound.go b/protocol/sudoku/outbound.go new file mode 100644 index 00000000..03902045 --- /dev/null +++ b/protocol/sudoku/outbound.go @@ -0,0 +1,401 @@ +package sudoku + +import ( + "context" + "fmt" + "net" + "strings" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/sudoku" + "github.com/sagernet/sing-box/transport/sudoku/obfs/httpmask" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.SudokuOutboundOptions](registry, C.TypeSudoku, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + logger logger.ContextLogger + dialer N.Dialer + tlsConfig tls.Config + baseConf sudoku.ProtocolConfig + + muxMu sync.Mutex + muxClient *sudoku.MultiplexClient + + httpMaskMu sync.Mutex + httpMaskClient *httpmask.TunnelClient + httpMaskKey string +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SudokuOutboundOptions) (adapter.Outbound, error) { + outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain()) + if err != nil { + return nil, err + } + + defaultConf := sudoku.DefaultConfig() + tableType, err := sudoku.NormalizeTableType(options.TableType) + if err != nil { + return nil, err + } + paddingMin, paddingMax := sudoku.ResolvePadding(options.PaddingMin, options.PaddingMax, defaultConf.PaddingMin, defaultConf.PaddingMax) + enablePureDownlink := sudoku.DerefBool(options.EnablePureDownlink, defaultConf.EnablePureDownlink) + + serverAddr := options.ServerOptions.Build() + + disableHTTPMask := defaultConf.DisableHTTPMask + httpMaskMode := defaultConf.HTTPMaskMode + var httpMaskHost string + var pathRoot string + httpMaskMultiplex := defaultConf.HTTPMaskMultiplex + + if hm := options.HTTPMask; hm != nil { + disableHTTPMask = !hm.Enabled + if hm.Mode != "" { + httpMaskMode = hm.Mode + } + httpMaskHost = hm.Host + pathRoot = strings.TrimSpace(hm.PathRoot) + if hm.Multiplex != "" { + httpMaskMultiplex = hm.Multiplex + } + } + + baseConf := sudoku.ProtocolConfig{ + ServerAddress: serverAddr.String(), + Key: options.Key, + AEADMethod: defaultConf.AEADMethod, + PaddingMin: paddingMin, + PaddingMax: paddingMax, + EnablePureDownlink: enablePureDownlink, + HandshakeTimeoutSeconds: defaultConf.HandshakeTimeoutSeconds, + DisableHTTPMask: disableHTTPMask, + HTTPMaskMode: httpMaskMode, + HTTPMaskHost: httpMaskHost, + HTTPMaskPathRoot: pathRoot, + HTTPMaskMultiplex: httpMaskMultiplex, + } + if options.AEADMethod != "" { + baseConf.AEADMethod = options.AEADMethod + } + + tables, err := sudoku.NewClientTablesWithCustomPatterns(sudoku.ClientAEADSeed(options.Key), tableType, options.CustomTable, options.CustomTables) + if err != nil { + return nil, E.Cause(err, "build table(s)") + } + if len(tables) == 1 { + baseConf.Table = tables[0] + } else { + baseConf.Tables = tables + } + + out := &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeSudoku, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.DialerOptions), + logger: logger, + dialer: outboundDialer, + baseConf: baseConf, + } + if hm := options.HTTPMask; !disableHTTPMask && hm != nil && hm.TLS != nil && hm.TLS.Enabled { + tlsOptions := option.OutboundTLSOptions{ + Enabled: true, + ServerName: options.Server, + Fragment: hm.TLS.Fragment, + FragmentFallbackDelay: hm.TLS.FragmentFallbackDelay, + RecordFragment: hm.TLS.RecordFragment, + KernelTx: hm.TLS.KernelTx, + KernelRx: hm.TLS.KernelRx, + } + out.tlsConfig, err = tls.NewClientWithOptions(tls.ClientOptions{ + Context: ctx, + Logger: logger, + ServerAddress: options.Server, + Options: tlsOptions, + }) + if err != nil { + return nil, err + } + } + return out, nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch N.NetworkName(network) { + case N.NetworkTCP: + h.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + } + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + + cfg := h.baseConf + cfg.TargetAddress = destination.String() + + muxMode := normalizeHTTPMaskMultiplex(cfg.HTTPMaskMultiplex) + if muxMode == "on" && !cfg.DisableHTTPMask && httpTunnelModeEnabled(cfg.HTTPMaskMode) { + stream, err := h.dialMultiplex(ctx, cfg.TargetAddress) + if err == nil { + return stream, nil + } + return nil, err + } + + c, err := h.dialAndHandshake(ctx, &cfg) + if err != nil { + return nil, err + } + + addrBuf, err := sudoku.EncodeAddress(cfg.TargetAddress) + if err != nil { + c.Close() + return nil, E.Cause(err, "encode target address") + } + if err = sudoku.WriteKIPMessage(c, sudoku.KIPTypeOpenTCP, addrBuf); err != nil { + c.Close() + return nil, E.Cause(err, "send target address") + } + + return c, nil +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + + cfg := h.baseConf + cfg.TargetAddress = destination.String() + + c, err := h.dialAndHandshake(ctx, &cfg) + if err != nil { + return nil, err + } + + if err = sudoku.WriteKIPMessage(c, sudoku.KIPTypeStartUoT, nil); err != nil { + c.Close() + return nil, E.Cause(err, "start uot") + } + + return bufio.NewBindPacketConn(sudoku.NewUoTPacketConn(c), destination), nil +} + +func (h *Outbound) Close() error { + h.resetMuxClient() + h.resetHTTPMaskClient() + return common.Close(h.tlsConfig) +} + +func (h *Outbound) InterfaceUpdated() { + h.resetMuxClient() + h.resetHTTPMaskClient() +} + +func (h *Outbound) dialAndHandshake(ctx context.Context, cfg *sudoku.ProtocolConfig) (net.Conn, error) { + handshakeCfg := *cfg + if !handshakeCfg.DisableHTTPMask && httpTunnelModeEnabled(handshakeCfg.HTTPMaskMode) { + handshakeCfg.DisableHTTPMask = true + } + + upgrade := func(raw net.Conn) (net.Conn, error) { + return sudoku.ClientHandshake(raw, &handshakeCfg) + } + + var c net.Conn + var err error + var handshakeDone bool + + if !cfg.DisableHTTPMask && httpTunnelModeEnabled(cfg.HTTPMaskMode) { + muxMode := normalizeHTTPMaskMultiplex(cfg.HTTPMaskMultiplex) + if muxMode == "auto" && strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) != "ws" { + if client, cerr := h.getOrCreateHTTPMaskClient(cfg); cerr == nil && client != nil { + c, err = client.DialTunnel(ctx, httpmask.TunnelDialOptions{ + Mode: cfg.HTTPMaskMode, + TLSConfig: h.httpMaskTLSConfig(), + HostOverride: cfg.HTTPMaskHost, + PathRoot: cfg.HTTPMaskPathRoot, + AuthKey: sudoku.ClientAEADSeed(cfg.Key), + Upgrade: upgrade, + Multiplex: cfg.HTTPMaskMultiplex, + DialContext: h.dialRaw, + }) + if err != nil { + h.resetHTTPMaskClient() + } + } + } + if c == nil && err == nil { + c, err = sudoku.DialHTTPMaskTunnel(ctx, cfg.ServerAddress, cfg, h.dialRaw, upgrade) + } + if err == nil && c != nil { + handshakeDone = true + } + } + if c == nil && err == nil { + c, err = h.dialer.DialContext(ctx, N.NetworkTCP, M.ParseSocksaddr(cfg.ServerAddress)) + } + if err != nil { + return nil, E.Cause(err, "connect to ", cfg.ServerAddress) + } + + if !handshakeDone { + c, err = sudoku.ClientHandshake(c, &handshakeCfg) + if err != nil { + common.Close(c) + return nil, err + } + } + + return c, nil +} + +func (h *Outbound) dialRaw(ctx context.Context, network, addr string) (net.Conn, error) { + return h.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) +} + +func (h *Outbound) httpMaskTLSConfig() httpmask.TLSClientConfig { + if h.tlsConfig == nil { + return nil + } + return tlsConfigAdapter{h.tlsConfig} +} + +type tlsConfigAdapter struct { + config tls.Config +} + +func (a tlsConfigAdapter) Client(conn net.Conn) (net.Conn, error) { + return a.config.Client(conn) +} + +func (h *Outbound) dialMultiplex(ctx context.Context, targetAddress string) (net.Conn, error) { + for attempt := 0; attempt < 2; attempt++ { + client, err := h.getOrCreateMuxClient(ctx) + if err != nil { + return nil, err + } + stream, err := client.Dial(ctx, targetAddress) + if err != nil { + h.resetMuxClient() + continue + } + return stream, nil + } + return nil, fmt.Errorf("multiplex open stream failed") +} + +func (h *Outbound) getOrCreateMuxClient(ctx context.Context) (*sudoku.MultiplexClient, error) { + h.muxMu.Lock() + defer h.muxMu.Unlock() + + if h.muxClient != nil && !h.muxClient.IsClosed() { + return h.muxClient, nil + } + + baseCfg := h.baseConf + baseConn, err := h.dialAndHandshake(ctx, &baseCfg) + if err != nil { + return nil, err + } + + client, err := sudoku.StartMultiplexClient(baseConn) + if err != nil { + baseConn.Close() + return nil, err + } + h.muxClient = client + return client, nil +} + +func (h *Outbound) resetMuxClient() { + h.muxMu.Lock() + defer h.muxMu.Unlock() + if h.muxClient != nil { + h.muxClient.Close() + h.muxClient = nil + } +} + +func (h *Outbound) getOrCreateHTTPMaskClient(cfg *sudoku.ProtocolConfig) (*httpmask.TunnelClient, error) { + key := cfg.ServerAddress + "|" + fmt.Sprint(h.tlsConfig != nil) + "|" + strings.TrimSpace(cfg.HTTPMaskHost) + + h.httpMaskMu.Lock() + if h.httpMaskClient != nil && h.httpMaskKey == key { + client := h.httpMaskClient + h.httpMaskMu.Unlock() + return client, nil + } + h.httpMaskMu.Unlock() + + client, err := httpmask.NewTunnelClient(cfg.ServerAddress, httpmask.TunnelClientOptions{ + TLSConfig: h.httpMaskTLSConfig(), + HostOverride: cfg.HTTPMaskHost, + DialContext: h.dialRaw, + MaxIdleConns: 32, + }) + if err != nil { + return nil, err + } + + h.httpMaskMu.Lock() + defer h.httpMaskMu.Unlock() + if h.httpMaskClient != nil && h.httpMaskKey == key { + client.CloseIdleConnections() + return h.httpMaskClient, nil + } + if h.httpMaskClient != nil { + h.httpMaskClient.CloseIdleConnections() + } + h.httpMaskClient = client + h.httpMaskKey = key + return client, nil +} + +func (h *Outbound) resetHTTPMaskClient() { + h.httpMaskMu.Lock() + defer h.httpMaskMu.Unlock() + if h.httpMaskClient != nil { + h.httpMaskClient.CloseIdleConnections() + h.httpMaskClient = nil + h.httpMaskKey = "" + } +} + +func normalizeHTTPMaskMultiplex(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "", "off": + return "off" + case "auto": + return "auto" + case "on": + return "on" + default: + return "off" + } +} + +func httpTunnelModeEnabled(mode string) bool { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "stream", "poll", "auto", "ws": + return true + default: + return false + } +} diff --git a/protocol/trusttunnel/inbound.go b/protocol/trusttunnel/inbound.go new file mode 100644 index 00000000..4b937b2f --- /dev/null +++ b/protocol/trusttunnel/inbound.go @@ -0,0 +1,190 @@ +package trusttunnel + +import ( + "context" + "errors" + "net" + "net/http" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/trusttunnel" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + aTLS "github.com/sagernet/sing/common/tls" + + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.TrustTunnelInboundOptions](registry, C.TypeTrustTunnel, NewInbound) +} + +type Inbound struct { + inbound.Adapter + ctx context.Context + router adapter.Router + logger logger.ContextLogger + listener *listener.Listener + tlsConfig tls.ServerConfig + service *trusttunnel.Service + httpServer *http.Server + quicService *trusttunnel.QUICService + network []string +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TrustTunnelInboundOptions) (adapter.Inbound, error) { + if options.TLS == nil || !options.TLS.Enabled { + return nil, C.ErrTLSRequired + } + tlsConfig, err := tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + networkList := options.Network.Build() + if len(networkList) == 0 { + networkList = []string{N.NetworkTCP} + } + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeTrustTunnel, tag), + ctx: ctx, + router: router, + logger: logger, + tlsConfig: tlsConfig, + network: networkList, + listener: listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Listen: options.ListenOptions, + }), + } + service := trusttunnel.NewService(trusttunnel.ServiceOptions{ + Ctx: ctx, + Logger: logger, + Handler: (*inboundHandler)(inbound), + }) + userMap := make(map[string]string, len(options.Users)) + for _, u := range options.Users { + userMap[u.Name] = u.Password + } + service.UpdateUsers(userMap) + inbound.service = service + if common.Contains(networkList, N.NetworkUDP) { + inbound.quicService = trusttunnel.NewQUICService(service, options.CongestionController, options.CWND, options.BBRProfile) + } + return inbound, nil +} + +func (h *Inbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + if h.tlsConfig != nil { + err := h.tlsConfig.Start() + if err != nil { + return err + } + } + if common.Contains(h.network, N.NetworkTCP) { + tcpListener, err := h.listener.ListenTCP() + if err != nil { + return err + } + h.httpServer = &http.Server{ + Handler: h2c.NewHandler(h.service, &http2.Server{}), + BaseContext: func(net.Listener) context.Context { + return h.ctx + }, + } + go func() { + var l net.Listener = tcpListener + if h.tlsConfig != nil { + if len(h.tlsConfig.NextProtos()) == 0 { + h.tlsConfig.SetNextProtos([]string{http2.NextProtoTLS}) + } else if !common.Contains(h.tlsConfig.NextProtos(), http2.NextProtoTLS) { + h.tlsConfig.SetNextProtos(append([]string{http2.NextProtoTLS}, h.tlsConfig.NextProtos()...)) + } + l = aTLS.NewListener(tcpListener, h.tlsConfig) + } + sErr := h.httpServer.Serve(l) + if sErr != nil && !errors.Is(sErr, http.ErrServerClosed) { + h.logger.Error("HTTP server error: ", sErr) + } + }() + } + if common.Contains(h.network, N.NetworkUDP) { + udpConn, err := h.listener.ListenUDP() + if err != nil { + return err + } + err = h.quicService.Start(h.ctx, udpConn, h.tlsConfig) + if err != nil { + return err + } + } + return nil +} + +func (h *Inbound) Close() error { + return common.Close( + h.listener, + common.PtrOrNil(h.httpServer), + h.quicService, + h.tlsConfig, + ) +} + +func (h *Inbound) UpdateUsers(users []option.TrustTunnelUser) { + userMap := make(map[string]string, len(users)) + for _, u := range users { + userMap[u.Name] = u.Password + } + h.service.UpdateUsers(userMap) +} + +type inboundHandler Inbound + +func (h *inboundHandler) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error { + var inboundCtx adapter.InboundContext + inboundCtx.Inbound = h.Tag() + inboundCtx.InboundType = h.Type() + //nolint:staticcheck + inboundCtx.InboundDetour = h.listener.ListenOptions().Detour + inboundCtx.Source = metadata.Source + inboundCtx.Destination = metadata.Destination + if userName, _ := auth.UserFromContext[string](ctx); userName != "" { + inboundCtx.User = userName + h.logger.InfoContext(ctx, "[", userName, "] inbound connection to ", inboundCtx.Destination) + } else { + h.logger.InfoContext(ctx, "inbound connection to ", inboundCtx.Destination) + } + h.router.RouteConnectionEx(ctx, conn, inboundCtx, nil) + return nil +} + +func (h *inboundHandler) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error { + var inboundCtx adapter.InboundContext + inboundCtx.Inbound = h.Tag() + inboundCtx.InboundType = h.Type() + //nolint:staticcheck + inboundCtx.InboundDetour = h.listener.ListenOptions().Detour + inboundCtx.Source = metadata.Source + inboundCtx.Destination = metadata.Destination + if userName, _ := auth.UserFromContext[string](ctx); userName != "" { + inboundCtx.User = userName + h.logger.InfoContext(ctx, "[", userName, "] inbound packet connection to ", inboundCtx.Destination) + } else { + h.logger.InfoContext(ctx, "inbound packet connection to ", inboundCtx.Destination) + } + h.router.RoutePacketConnectionEx(ctx, conn, inboundCtx, nil) + return nil +} diff --git a/protocol/trusttunnel/outbound.go b/protocol/trusttunnel/outbound.go new file mode 100644 index 00000000..b75f7068 --- /dev/null +++ b/protocol/trusttunnel/outbound.go @@ -0,0 +1,118 @@ +package trusttunnel + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/trusttunnel" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "golang.org/x/net/http2" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.TrustTunnelOutboundOptions](registry, C.TypeTrustTunnel, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + logger logger.ContextLogger + client trusttunnel.Dialer +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TrustTunnelOutboundOptions) (adapter.Outbound, error) { + if options.TLS == nil || !options.TLS.Enabled { + return nil, C.ErrTLSRequired + } + outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain()) + if err != nil { + return nil, err + } + serverAddr := options.ServerOptions.Build() + networkList := options.Network.Build() + clientOpts := trusttunnel.ClientOptions{ + Server: serverAddr, + Username: options.Username, + Password: options.Password, + QUIC: options.QUIC, + CongestionControl: options.CongestionController, + CWND: options.CWND, + BBRProfile: options.BBRProfile, + HealthCheck: options.HealthCheck, + } + if options.QUIC { + tlsConfig, err := tls.NewClient(ctx, logger, options.Server, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + if len(tlsConfig.NextProtos()) == 0 { + tlsConfig.SetNextProtos([]string{"h3"}) + } + clientOpts.QUICDialer = outboundDialer + clientOpts.QUICTLSConfig = tlsConfig + } else { + tlsConfig, err := tls.NewClient(ctx, logger, options.Server, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + if len(tlsConfig.NextProtos()) == 0 { + tlsConfig.SetNextProtos([]string{http2.NextProtoTLS}) + } + clientOpts.TLSDialer = tls.NewDialer(outboundDialer, tlsConfig) + } + var client trusttunnel.Dialer + if options.Multiplex != nil && options.Multiplex.Enabled { + clientOpts.MaxConnections = options.Multiplex.MaxConnections + clientOpts.MinStreams = options.Multiplex.MinStreams + clientOpts.MaxStreams = options.Multiplex.MaxStreams + client, err = trusttunnel.NewMultiplexClient(ctx, clientOpts) + } else { + client, err = trusttunnel.NewClient(ctx, clientOpts) + } + if err != nil { + return nil, err + } + return &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeTrustTunnel, tag, networkList, options.DialerOptions), + logger: logger, + client: client, + }, nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch N.NetworkName(network) { + case N.NetworkTCP: + h.logger.InfoContext(ctx, "outbound connection to ", destination) + return h.client.Dial(ctx, destination.String()) + case N.NetworkUDP: + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + conn, err := h.client.ListenPacket(ctx) + if err != nil { + return nil, err + } + return bufio.NewBindPacketConn(conn, destination), nil + default: + return nil, E.New("unsupported network: ", network) + } +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + return h.client.ListenPacket(ctx) +} + +func (h *Outbound) Close() error { + return h.client.Close() +} diff --git a/provider/local/provider.go b/provider/local/provider.go index a8a9f405..89cd3d86 100644 --- a/provider/local/provider.go +++ b/provider/local/provider.go @@ -46,13 +46,14 @@ func NewProviderInline(ctx context.Context, router adapter.Router, logFactory lo outbound = service.FromContext[adapter.OutboundManager](ctx) logger = logFactory.NewLogger(F.ToString("provider/inline", "[", tag, "]")) ) - provider := &ProviderLocal{ + p := &ProviderLocal{ Adapter: provider.NewAdapter(ctx, router, outbound, logFactory, logger, tag, C.ProviderTypeInline, options.HealthCheck), ctx: ctx, logger: logger, } - provider.UpdateOutbounds(nil, options.Outbounds) - return provider, nil + p.SetRemoveEmojis(options.RemoveEmojis) + p.UpdateOutbounds(nil, options.Outbounds) + return p, nil } func NewProviderLocal(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options option.ProviderLocalOptions) (adapter.Provider, error) { @@ -69,6 +70,7 @@ func NewProviderLocal(ctx context.Context, router adapter.Router, logFactory log logger: logger, provider: service.FromContext[adapter.ProviderManager](ctx), } + provider.SetRemoveEmojis(options.RemoveEmojis) filePath := filemanager.BasePath(ctx, options.Path) provider.path, _ = filepath.Abs(filePath) watcher, err := fswatch.NewWatcher(fswatch.Options{ diff --git a/provider/remote/provider.go b/provider/remote/provider.go index 906c333e..deef93d6 100644 --- a/provider/remote/provider.go +++ b/provider/remote/provider.go @@ -83,7 +83,7 @@ func NewProviderRemote(ctx context.Context, router adapter.Router, logFactory lo logger := logFactory.NewLogger(F.ToString("provider/remote", "[", tag, "]")) updateChan := make(chan struct{}) close(updateChan) - return &ProviderRemote{ + p := &ProviderRemote{ Adapter: provider.NewAdapter(ctx, router, outbound, logFactory, logger, tag, C.ProviderTypeRemote, options.HealthCheck), ctx: ctx, cancel: cancel, @@ -97,7 +97,9 @@ func NewProviderRemote(ctx context.Context, router adapter.Router, logFactory lo updateInterval: updateInterval, exclude: (*regexp.Regexp)(options.Exclude), include: (*regexp.Regexp)(options.Include), - }, nil + } + p.SetRemoveEmojis(options.RemoveEmojis) + return p, nil } func (s *ProviderRemote) Start() error { diff --git a/release/DEFAULT_BUILD_TAGS b/release/DEFAULT_BUILD_TAGS index 4374ea93..03380949 100644 --- a/release/DEFAULT_BUILD_TAGS +++ b/release/DEFAULT_BUILD_TAGS @@ -1 +1 @@ -with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_naive_outbound,badlinkname,tfogo_checklinkname0 \ No newline at end of file +with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_masque,with_mtproxy,with_ccm,with_ocm,with_openvpn,with_trusttunnel,with_sudoku,with_naive_outbound,badlinkname,tfogo_checklinkname0 \ No newline at end of file diff --git a/release/DEFAULT_BUILD_TAGS_OTHERS b/release/DEFAULT_BUILD_TAGS_OTHERS index a06e6c8b..3c99d6ad 100644 --- a/release/DEFAULT_BUILD_TAGS_OTHERS +++ b/release/DEFAULT_BUILD_TAGS_OTHERS @@ -1 +1 @@ -with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_manager,with_admin_panel,with_ccm,with_ocm,badlinkname,tfogo_checklinkname0 +with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_manager,with_admin_panel,with_masque,with_mtproxy,with_ccm,with_ocm,with_openvpn,with_trusttunnel,with_sudoku,badlinkname,tfogo_checklinkname0 diff --git a/release/DEFAULT_BUILD_TAGS_WINDOWS b/release/DEFAULT_BUILD_TAGS_WINDOWS index 746827a7..bd722436 100644 --- a/release/DEFAULT_BUILD_TAGS_WINDOWS +++ b/release/DEFAULT_BUILD_TAGS_WINDOWS @@ -1 +1 @@ -with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_naive_outbound,with_purego,badlinkname,tfogo_checklinkname0 \ No newline at end of file +with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_masque,with_mtproxy,with_ccm,with_ocm,with_openvpn,with_trusttunnel,with_sudoku,with_naive_outbound,with_purego,badlinkname,tfogo_checklinkname0 \ No newline at end of file diff --git a/service/admin_panel/web/src/api/types.ts b/service/admin_panel/web/src/api/types.ts index 3a410395..feda56fa 100644 --- a/service/admin_panel/web/src/api/types.ts +++ b/service/admin_panel/web/src/api/types.ts @@ -34,10 +34,16 @@ export interface NodeUpdate { export type NodeStatus = "online" | "offline"; export type UserType = + | "anytls" + | "http" | "hysteria" | "hysteria2" + | "mixed" | "mtproxy" + | "naive" + | "socks" | "trojan" + | "trusttunnel" | "tuic" | "vless" | "vmess"; diff --git a/service/admin_panel/web/src/pages/BandwidthLimitersPage.tsx b/service/admin_panel/web/src/pages/BandwidthLimitersPage.tsx index a3badc23..8ae2d050 100644 --- a/service/admin_panel/web/src/pages/BandwidthLimitersPage.tsx +++ b/service/admin_panel/web/src/pages/BandwidthLimitersPage.tsx @@ -44,10 +44,11 @@ const CONN_TYPES: { value: ConnectionType; label: string }[] = [ ]; const FLOW_KEYS: { value: string; label: string }[] = [ { value: "user", label: "User" }, - { value: "destination", label: "Destination" }, + { value: "source_ip", label: "Source IP" }, { value: "hwid", label: "HWID" }, { value: "mux", label: "Mux" }, - { value: "source_ip", label: "Source IP" }, + { value: "protocol", label: "Protocol" }, + { value: "destination", label: "Destination" }, ]; export function BandwidthLimitersPage() { diff --git a/service/admin_panel/web/src/pages/UsersPage.tsx b/service/admin_panel/web/src/pages/UsersPage.tsx index 8412724c..e3403608 100644 --- a/service/admin_panel/web/src/pages/UsersPage.tsx +++ b/service/admin_panel/web/src/pages/UsersPage.tsx @@ -17,10 +17,16 @@ import { // Display labels mirror service/admin_panel/tables/user.go. const USER_TYPES: { value: UserType; label: string }[] = [ + { value: "anytls", label: "AnyTLS" }, + { value: "http", label: "HTTP" }, { value: "hysteria", label: "Hysteria" }, { value: "hysteria2", label: "Hysteria2" }, + { value: "mixed", label: "Mixed" }, { value: "mtproxy", label: "MTProxy" }, + { value: "naive", label: "Naive" }, + { value: "socks", label: "SOCKS" }, { value: "trojan", label: "Trojan" }, + { value: "trusttunnel", label: "TrustTunnel" }, { value: "tuic", label: "TUIC" }, { value: "vless", label: "VLESS" }, { value: "vmess", label: "VMess" }, @@ -38,7 +44,7 @@ const FLOW_OPTIONS: { value: string; label: string }[] = [ // same rule up-front (required fields invisible for the current type // are filtered out before validateRequired runs). const SHOW_UUID = new Set(["vless", "vmess", "tuic"]); -const SHOW_PASSWORD = new Set(["hysteria", "hysteria2", "trojan", "tuic"]); +const SHOW_PASSWORD = new Set(["anytls", "http", "hysteria", "hysteria2", "mixed", "naive", "socks", "trojan", "trusttunnel", "tuic"]); const SHOW_SECRET = new Set(["mtproxy"]); const SHOW_FLOW = new Set(["vless"]); const SHOW_ALTER_ID = new Set(["vmess"]); diff --git a/service/manager/constant/dto.go b/service/manager/constant/dto.go index a34fca71..4869c9b1 100644 --- a/service/manager/constant/dto.go +++ b/service/manager/constant/dto.go @@ -67,7 +67,7 @@ type UserCreate struct { SquadIDs []int `json:"squad_ids" validate:"required,min=1"` Username string `json:"username" validate:"required"` Inbound string `json:"inbound" validate:"required"` - Type string `json:"type" validate:"required,oneof=hysteria hysteria2 mtproxy trojan tuic vless vmess"` + Type string `json:"type" validate:"required,oneof=anytls http hysteria hysteria2 mixed mtproxy naive socks trojan trusttunnel tuic vless vmess"` UUID string `json:"uuid" validate:"omitempty,uuid4"` Password string `json:"password" validate:"omitempty"` Secret string `json:"secret" validate:"omitempty"` @@ -140,7 +140,7 @@ type BandwidthLimiter struct { Strategy string `json:"strategy" validate:"required"` ConnectionType string `json:"connection_type" validate:"omitempty"` Mode string `json:"mode" validate:"required"` - FlowKeys []string `json:"flow_keys" validate:"omitempty,dive,oneof=user destination ip hwid mux"` + FlowKeys []string `json:"flow_keys" validate:"omitempty,dive,oneof=user source_ip hwid mux protocol destination"` Speed string `json:"speed" validate:"required"` RawSpeed uint64 `json:"raw_speed" validate:"required"` CreatedAt time.Time `json:"created_at" validate:"required"` @@ -154,7 +154,7 @@ type BandwidthLimiterCreate struct { Strategy string `json:"strategy" validate:"required,oneof=global connection bypass"` ConnectionType string `json:"connection_type" validate:"excluded_if=Strategy bypass,omitempty"` Mode string `json:"mode" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"` - FlowKeys []string `json:"flow_keys" validate:"excluded_if=Strategy bypass,omitempty,dive,oneof=user destination ip hwid mux"` + FlowKeys []string `json:"flow_keys" validate:"excluded_if=Strategy bypass,omitempty,dive,oneof=user source_ip hwid mux protocol destination"` Speed string `json:"speed" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"` } @@ -164,7 +164,7 @@ type BandwidthLimiterUpdate struct { Strategy string `json:"strategy" validate:"required,oneof=global connection bypass"` ConnectionType string `json:"connection_type" validate:"excluded_if=Strategy bypass,omitempty"` Mode string `json:"mode" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"` - FlowKeys []string `json:"flow_keys" validate:"excluded_if=Strategy bypass,omitempty,dive,oneof=user destination ip hwid mux"` + FlowKeys []string `json:"flow_keys" validate:"excluded_if=Strategy bypass,omitempty,dive,oneof=user source_ip hwid mux protocol destination"` Speed string `json:"speed" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"` } @@ -174,7 +174,7 @@ type BaseBandwidthLimiter struct { Strategy string `json:"strategy" validate:"required,oneof=global connection bypass"` ConnectionType string `json:"connection_type" validate:"excluded_if=Strategy bypass,omitempty"` Mode string `json:"mode" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"` - FlowKeys []string `json:"flow_keys" validate:"excluded_if=Strategy bypass,omitempty,dive,oneof=user destination ip hwid mux"` + FlowKeys []string `json:"flow_keys" validate:"excluded_if=Strategy bypass,omitempty,dive,oneof=user source_ip hwid mux protocol destination"` Speed string `json:"speed" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"` RawSpeed uint64 `json:"raw_speed" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"` } @@ -261,3 +261,5 @@ type BaseRateLimiter struct { Count uint32 `json:"count" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"` Interval string `json:"interval" validate:"excluded_if=Strategy bypass,required_unless=Strategy bypass"` } + + diff --git a/service/manager/constant/repository.go b/service/manager/constant/repository.go index 7de46800..c700fe6f 100644 --- a/service/manager/constant/repository.go +++ b/service/manager/constant/repository.go @@ -51,3 +51,5 @@ type Repository interface { UpdateRateLimiter(id int, limiter RateLimiterUpdate) (RateLimiter, error) DeleteRateLimiter(id int) (RateLimiter, error) } + + diff --git a/service/manager/repository/postgresql/filter.go b/service/manager/repository/postgresql/filter.go index 0e61a42b..f91cd3ed 100644 --- a/service/manager/repository/postgresql/filter.go +++ b/service/manager/repository/postgresql/filter.go @@ -2,10 +2,12 @@ package postgresql import ( "encoding/json" + "slices" "strconv" "github.com/huandu/go-sqlbuilder" - "github.com/sagernet/sing-box/common/byteformats" + "github.com/sagernet/sing/common/byteformats" + E "github.com/sagernet/sing/common/exceptions" ) type Filter func(sb *sqlbuilder.SelectBuilder, value []string) error @@ -84,12 +86,13 @@ func SpeedLessEqualThanFilter(field string) Filter { } } -func ExistsAndWhereInFilter(subquery *sqlbuilder.SelectBuilder, field string) Filter { +func ExistsAndWhereInFilter(subqueryFactory func() *sqlbuilder.SelectBuilder, field string) Filter { return func(sb *sqlbuilder.SelectBuilder, value []string) error { values := make([]interface{}, len(value)) for i, v := range value { values[i] = v } + subquery := subqueryFactory() subquery.Where(subquery.In(field, values...)) sb.Where(sb.Exists(subquery)) return nil @@ -110,38 +113,54 @@ func InFilter(field string) Filter { } } -func SortAscFilter() Filter { +func SortAscFilter(columns []string) Filter { return func(sb *sqlbuilder.SelectBuilder, value []string) error { - sb.OrderByAsc(value[0]) - return nil - } -} - -func SortDescFilter() Filter { - return func(sb *sqlbuilder.SelectBuilder, value []string) error { - sb.OrderByDesc(value[0]) - return nil - } -} - -func ReplacedSortAscFilter(replace map[string]string) Filter { - return func(sb *sqlbuilder.SelectBuilder, value []string) error { - if replacedValue, ok := replace[value[0]]; ok { - sb.OrderByAsc(replacedValue) - } else { - sb.OrderByAsc(value[0]) + column, err := isValidSortColumn(value[0], columns) + if err != nil { + return err } + sb.OrderByAsc(column) return nil } } -func ReplacedSortDescFilter(replace map[string]string) Filter { +func SortDescFilter(columns []string) Filter { return func(sb *sqlbuilder.SelectBuilder, value []string) error { - if replacedValue, ok := replace[value[0]]; ok { - sb.OrderByDesc(replacedValue) - } else { - sb.OrderByDesc(value[0]) + column, err := isValidSortColumn(value[0], columns) + if err != nil { + return err } + sb.OrderByDesc(column) + return nil + } +} + +func ReplacedSortAscFilter(replace map[string]string, columns []string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + column, ok := replace[value[0]] + if !ok { + column = value[0] + } + column, err := isValidSortColumn(column, columns) + if err != nil { + return err + } + sb.OrderByAsc(column) + return nil + } +} + +func ReplacedSortDescFilter(replace map[string]string, columns []string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + column, ok := replace[value[0]] + if !ok { + column = value[0] + } + column, err := isValidSortColumn(column, columns) + if err != nil { + return err + } + sb.OrderByDesc(column) return nil } } @@ -167,3 +186,10 @@ func OffsetFilter() Filter { return nil } } + +func isValidSortColumn(column string, columns []string) (string, error) { + if slices.Contains(columns, column) { + return column, nil + } + return "", E.New("invalid sort column \"", column, "\"") +} diff --git a/service/manager/repository/postgresql/repository.go b/service/manager/repository/postgresql/repository.go index b2777bcf..05489f0b 100644 --- a/service/manager/repository/postgresql/repository.go +++ b/service/manager/repository/postgresql/repository.go @@ -4,14 +4,15 @@ import ( "context" "database/sql" "encoding/json" + "errors" "time" "github.com/golang-migrate/migrate/v4" "github.com/huandu/go-sqlbuilder" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" - "github.com/sagernet/sing-box/common/byteformats" "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing/common/byteformats" ) var squadFilters, nodeFilters, userFilters, bandwidthLimiterFilters, connectionLimiterFilters, trafficLimiterFilters, rateLimiterFilters map[string]Filter @@ -37,6 +38,13 @@ func NewPostgreSQLRepository(ctx context.Context, dsn string) (*PostgreSQLReposi return &PostgreSQLRepository{db: pool, ctx: ctx}, nil } +func notFoundErr(err error) error { + if errors.Is(err, pgx.ErrNoRows) { + return constant.ErrNotFound + } + return err +} + func (r *PostgreSQLRepository) CreateSquad(squad constant.SquadCreate) (constant.Squad, error) { var s constant.Squad now := time.Now() @@ -138,7 +146,7 @@ func (r *PostgreSQLRepository) GetSquad(id int) (constant.Squad, error) { &s.CreatedAt, &s.UpdatedAt, ) - return s, err + return s, notFoundErr(err) } func (r *PostgreSQLRepository) UpdateSquad(id int, squad constant.SquadUpdate) (constant.Squad, error) { @@ -450,10 +458,7 @@ func (r *PostgreSQLRepository) GetNode(uuid string) (constant.Node, error) { &n.CreatedAt, &n.UpdatedAt, ) - if err != nil && err.Error() == "no rows in result set" { - return n, constant.ErrNotFound - } - return n, err + return n, notFoundErr(err) } func (r *PostgreSQLRepository) UpdateNode(uuid string, node constant.NodeUpdate) (constant.Node, error) { @@ -696,7 +701,7 @@ func (r *PostgreSQLRepository) GetUser(id int) (constant.User, error) { &u.CreatedAt, &u.UpdatedAt, ) - return u, err + return u, notFoundErr(err) } func (r *PostgreSQLRepository) UpdateUser(id int, user constant.UserUpdate) (constant.User, error) { @@ -974,7 +979,7 @@ func (r *PostgreSQLRepository) GetConnectionLimiter(id int) (constant.Connection &cl.CreatedAt, &cl.UpdatedAt, ) - return cl, err + return cl, notFoundErr(err) } func (r *PostgreSQLRepository) UpdateConnectionLimiter(id int, limiter constant.ConnectionLimiterUpdate) (constant.ConnectionLimiter, error) { @@ -1275,7 +1280,7 @@ func (r *PostgreSQLRepository) GetBandwidthLimiter(id int) (constant.BandwidthLi &bl.UpdatedAt, ) bl.FlowKeys = []string(flowKeys) - return bl, err + return bl, notFoundErr(err) } func (r *PostgreSQLRepository) UpdateBandwidthLimiter(id int, limiter constant.BandwidthLimiterUpdate) (constant.BandwidthLimiter, error) { @@ -1594,7 +1599,7 @@ func (r *PostgreSQLRepository) GetTrafficLimiter(id int) (constant.TrafficLimite &tl.CreatedAt, &tl.UpdatedAt, ) - return tl, err + return tl, notFoundErr(err) } func (r *PostgreSQLRepository) UpdateTrafficLimiter(id int, limiter constant.TrafficLimiterUpdate) (constant.TrafficLimiter, error) { @@ -1928,7 +1933,7 @@ func (r *PostgreSQLRepository) GetRateLimiter(id int) (constant.RateLimiter, err &rl.CreatedAt, &rl.UpdatedAt, ) - return rl, err + return rl, notFoundErr(err) } func (r *PostgreSQLRepository) UpdateRateLimiter(id int, limiter constant.RateLimiterUpdate) (constant.RateLimiter, error) { @@ -2029,8 +2034,8 @@ func init() { "created_at_end": LessThanFilter("created_at"), "updated_at_start": GreaterThanFilter("updated_at"), "updated_at_end": LessThanFilter("updated_at"), - "sort_asc": SortAscFilter(), - "sort_desc": SortDescFilter(), + "sort_asc": SortAscFilter([]string{"id", "name", "created_at", "updated_at"}), + "sort_desc": SortDescFilter([]string{"id", "name", "created_at", "updated_at"}), "offset": OffsetFilter(), "limit": LimitFilter(), } @@ -2038,8 +2043,8 @@ func init() { "uuid": EqualFilter("uuid"), "pk": EqualFilter("uuid"), "name": EqualFilter("name"), - "squad_id_in": ExistsAndWhereInFilter( - sqlbuilder.PostgreSQL.NewSelectBuilder(). + "squad_id_in": ExistsAndWhereInFilter(func() *sqlbuilder.SelectBuilder { + return sqlbuilder.PostgreSQL.NewSelectBuilder(). Select( "squad_id", ). @@ -2048,23 +2053,22 @@ func init() { ). From( "node_to_squad", - ), - "node_to_squad.squad_id", - ), + ) + }, "node_to_squad.squad_id"), "created_at_start": GreaterThanFilter("created_at"), "created_at_end": LessThanFilter("created_at"), "updated_at_start": GreaterThanFilter("updated_at"), "updated_at_end": LessThanFilter("updated_at"), - "sort_asc": SortAscFilter(), - "sort_desc": SortDescFilter(), + "sort_asc": SortAscFilter([]string{"uuid", "name", "created_at", "updated_at"}), + "sort_desc": SortDescFilter([]string{"uuid", "name", "created_at", "updated_at"}), "offset": OffsetFilter(), "limit": LimitFilter(), } userFilters = map[string]Filter{ "id": EqualFilter("id"), "pk": EqualFilter("id"), - "squad_id_in": ExistsAndWhereInFilter( - sqlbuilder.PostgreSQL.NewSelectBuilder(). + "squad_id_in": ExistsAndWhereInFilter(func() *sqlbuilder.SelectBuilder { + return sqlbuilder.PostgreSQL.NewSelectBuilder(). Select( "squad_id", ). @@ -2073,26 +2077,24 @@ func init() { ). From( "user_to_squad", - ), - "user_to_squad.squad_id", - ), + ) + }, "user_to_squad.squad_id"), "username": EqualFilter("username"), "inbound": EqualFilter("inbound"), - "type": EqualFilter("type"), "created_at_start": GreaterThanFilter("created_at"), "created_at_end": LessThanFilter("created_at"), "updated_at_start": GreaterThanFilter("updated_at"), "updated_at_end": LessThanFilter("updated_at"), - "sort_asc": SortAscFilter(), - "sort_desc": SortDescFilter(), + "sort_asc": SortAscFilter([]string{"id", "username", "inbound", "type", "created_at", "updated_at"}), + "sort_desc": SortDescFilter([]string{"id", "username", "inbound", "type", "created_at", "updated_at"}), "offset": OffsetFilter(), "limit": LimitFilter(), } connectionLimiterFilters = map[string]Filter{ "id": EqualFilter("id"), "pk": EqualFilter("id"), - "squad_id_in": ExistsAndWhereInFilter( - sqlbuilder.PostgreSQL.NewSelectBuilder(). + "squad_id_in": ExistsAndWhereInFilter(func() *sqlbuilder.SelectBuilder { + return sqlbuilder.PostgreSQL.NewSelectBuilder(). Select( "squad_id", ). @@ -2101,9 +2103,8 @@ func init() { ). From( "connection_limiter_to_squad", - ), - "connection_limiter_to_squad.squad_id", - ), + ) + }, "connection_limiter_to_squad.squad_id"), "strategy": EqualFilter("strategy"), "username": EqualFilter("username"), "outbound": EqualFilter("outbound"), @@ -2113,16 +2114,16 @@ func init() { "created_at_end": LessThanFilter("created_at"), "updated_at_start": GreaterThanFilter("updated_at"), "updated_at_end": LessThanFilter("updated_at"), - "sort_asc": SortAscFilter(), - "sort_desc": SortDescFilter(), + "sort_asc": SortAscFilter([]string{"id", "username", "outbound", "strategy", "connection_type", "lock_type", "count", "created_at", "updated_at"}), + "sort_desc": SortDescFilter([]string{"id", "username", "outbound", "strategy", "connection_type", "lock_type", "count", "created_at", "updated_at"}), "offset": OffsetFilter(), "limit": LimitFilter(), } bandwidthLimiterFilters = map[string]Filter{ "id": EqualFilter("id"), "pk": EqualFilter("id"), - "squad_id_in": ExistsAndWhereInFilter( - sqlbuilder.PostgreSQL.NewSelectBuilder(). + "squad_id_in": ExistsAndWhereInFilter(func() *sqlbuilder.SelectBuilder { + return sqlbuilder.PostgreSQL.NewSelectBuilder(). Select( "squad_id", ). @@ -2131,31 +2132,31 @@ func init() { ). From( "bandwidth_limiter_to_squad", - ), - "bandwidth_limiter_to_squad.squad_id", - ), + ) + }, "bandwidth_limiter_to_squad.squad_id"), "strategy": EqualFilter("strategy"), "mode": EqualFilter("mode"), - "type": EqualFilter("type"), "username": EqualFilter("username"), - "down_start": SpeedGreaterEqualThanFilter("raw_down"), - "down_end": SpeedLessEqualThanFilter("raw_down"), - "up_start": SpeedGreaterEqualThanFilter("raw_up"), - "up_end": SpeedLessEqualThanFilter("raw_up"), "created_at_start": GreaterThanFilter("created_at"), "created_at_end": LessThanFilter("created_at"), "updated_at_start": GreaterThanFilter("updated_at"), "updated_at_end": LessThanFilter("updated_at"), - "sort_asc": ReplacedSortAscFilter(map[string]string{"down": "raw_down", "up": "raw_up"}), - "sort_desc": ReplacedSortDescFilter(map[string]string{"down": "raw_down", "up": "raw_up"}), - "offset": OffsetFilter(), - "limit": LimitFilter(), + "sort_asc": ReplacedSortAscFilter( + map[string]string{"speed": "raw_speed"}, + []string{"id", "username", "outbound", "strategy", "mode", "raw_speed", "created_at", "updated_at"}, + ), + "sort_desc": ReplacedSortDescFilter( + map[string]string{"speed": "raw_speed"}, + []string{"id", "username", "outbound", "strategy", "mode", "raw_speed", "created_at", "updated_at"}, + ), + "offset": OffsetFilter(), + "limit": LimitFilter(), } trafficLimiterFilters = map[string]Filter{ "id": EqualFilter("id"), "pk": EqualFilter("id"), - "squad_id_in": ExistsAndWhereInFilter( - sqlbuilder.PostgreSQL.NewSelectBuilder(). + "squad_id_in": ExistsAndWhereInFilter(func() *sqlbuilder.SelectBuilder { + return sqlbuilder.PostgreSQL.NewSelectBuilder(). Select( "squad_id", ). @@ -2164,31 +2165,36 @@ func init() { ). From( "traffic_limiter_to_squad", - ), - "traffic_limiter_to_squad.squad_id", - ), + ) + }, "traffic_limiter_to_squad.squad_id"), "username": EqualFilter("username"), "outbound": EqualFilter("outbound"), "strategy": EqualFilter("strategy"), "mode": EqualFilter("mode"), - "used_start": SpeedGreaterEqualThanFilter("raw_used"), - "used_end": SpeedLessEqualThanFilter("raw_used"), + "used_start": SpeedGreaterEqualThanFilter("raw_used"), + "used_end": SpeedLessEqualThanFilter("raw_used"), "quota_start": SpeedGreaterEqualThanFilter("raw_quota"), "quota_end": SpeedLessEqualThanFilter("raw_quota"), "created_at_start": GreaterThanFilter("created_at"), "created_at_end": LessThanFilter("created_at"), "updated_at_start": GreaterThanFilter("updated_at"), "updated_at_end": LessThanFilter("updated_at"), - "sort_asc": ReplacedSortAscFilter(map[string]string{"used": "raw_used", "quota": "raw_quota"}), - "sort_desc": ReplacedSortDescFilter(map[string]string{"used": "raw_used", "quota": "raw_quota"}), - "offset": OffsetFilter(), - "limit": LimitFilter(), + "sort_asc": ReplacedSortAscFilter( + map[string]string{"used": "raw_used", "quota": "raw_quota"}, + []string{"id", "username", "outbound", "strategy", "mode", "raw_used", "raw_quota", "created_at", "updated_at"}, + ), + "sort_desc": ReplacedSortDescFilter( + map[string]string{"used": "raw_used", "quota": "raw_quota"}, + []string{"id", "username", "outbound", "strategy", "mode", "raw_used", "raw_quota", "created_at", "updated_at"}, + ), + "offset": OffsetFilter(), + "limit": LimitFilter(), } rateLimiterFilters = map[string]Filter{ "id": EqualFilter("id"), "pk": EqualFilter("id"), - "squad_id_in": ExistsAndWhereInFilter( - sqlbuilder.PostgreSQL.NewSelectBuilder(). + "squad_id_in": ExistsAndWhereInFilter(func() *sqlbuilder.SelectBuilder { + return sqlbuilder.PostgreSQL.NewSelectBuilder(). Select( "squad_id", ). @@ -2197,9 +2203,8 @@ func init() { ). From( "rate_limiter_to_squad", - ), - "rate_limiter_to_squad.squad_id", - ), + ) + }, "rate_limiter_to_squad.squad_id"), "strategy": EqualFilter("strategy"), "username": EqualFilter("username"), "outbound": EqualFilter("outbound"), @@ -2211,8 +2216,8 @@ func init() { "created_at_end": LessThanFilter("created_at"), "updated_at_start": GreaterThanFilter("updated_at"), "updated_at_end": LessThanFilter("updated_at"), - "sort_asc": SortAscFilter(), - "sort_desc": SortDescFilter(), + "sort_asc": SortAscFilter([]string{"id", "username", "outbound", "strategy", "connection_type", "count", "interval", "created_at", "updated_at"}), + "sort_desc": SortDescFilter([]string{"id", "username", "outbound", "strategy", "connection_type", "count", "interval", "created_at", "updated_at"}), "offset": OffsetFilter(), "limit": LimitFilter(), } diff --git a/service/manager/repository/sqlite/filter.go b/service/manager/repository/sqlite/filter.go index 1c10afc2..a4f4ea75 100644 --- a/service/manager/repository/sqlite/filter.go +++ b/service/manager/repository/sqlite/filter.go @@ -2,10 +2,12 @@ package sqlite import ( "encoding/json" + "slices" "strconv" "github.com/huandu/go-sqlbuilder" - "github.com/sagernet/sing-box/common/byteformats" + "github.com/sagernet/sing/common/byteformats" + E "github.com/sagernet/sing/common/exceptions" ) type Filter func(sb *sqlbuilder.SelectBuilder, value []string) error @@ -84,12 +86,13 @@ func SpeedLessEqualThanFilter(field string) Filter { } } -func ExistsAndWhereInFilter(subquery *sqlbuilder.SelectBuilder, field string) Filter { +func ExistsAndWhereInFilter(subqueryFactory func() *sqlbuilder.SelectBuilder, field string) Filter { return func(sb *sqlbuilder.SelectBuilder, value []string) error { values := make([]interface{}, len(value)) for i, v := range value { values[i] = v } + subquery := subqueryFactory() subquery.Where(subquery.In(field, values...)) sb.Where(sb.Exists(subquery)) return nil @@ -110,38 +113,54 @@ func InFilter(field string) Filter { } } -func SortAscFilter() Filter { +func SortAscFilter(columns []string) Filter { return func(sb *sqlbuilder.SelectBuilder, value []string) error { - sb.OrderByAsc(value[0]) - return nil - } -} - -func SortDescFilter() Filter { - return func(sb *sqlbuilder.SelectBuilder, value []string) error { - sb.OrderByDesc(value[0]) - return nil - } -} - -func ReplacedSortAscFilter(replace map[string]string) Filter { - return func(sb *sqlbuilder.SelectBuilder, value []string) error { - if replacedValue, ok := replace[value[0]]; ok { - sb.OrderByAsc(replacedValue) - } else { - sb.OrderByAsc(value[0]) + column, err := isValidSortColumn(value[0], columns) + if err != nil { + return err } + sb.OrderByAsc(column) return nil } } -func ReplacedSortDescFilter(replace map[string]string) Filter { +func SortDescFilter(columns []string) Filter { return func(sb *sqlbuilder.SelectBuilder, value []string) error { - if replacedValue, ok := replace[value[0]]; ok { - sb.OrderByDesc(replacedValue) - } else { - sb.OrderByDesc(value[0]) + column, err := isValidSortColumn(value[0], columns) + if err != nil { + return err } + sb.OrderByDesc(column) + return nil + } +} + +func ReplacedSortAscFilter(replace map[string]string, columns []string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + column, ok := replace[value[0]] + if !ok { + column = value[0] + } + column, err := isValidSortColumn(column, columns) + if err != nil { + return err + } + sb.OrderByAsc(column) + return nil + } +} + +func ReplacedSortDescFilter(replace map[string]string, columns []string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + column, ok := replace[value[0]] + if !ok { + column = value[0] + } + column, err := isValidSortColumn(column, columns) + if err != nil { + return err + } + sb.OrderByDesc(column) return nil } } @@ -167,3 +186,10 @@ func OffsetFilter() Filter { return nil } } + +func isValidSortColumn(column string, columns []string) (string, error) { + if slices.Contains(columns, column) { + return column, nil + } + return "", E.New("invalid sort column \"", column, "\"") +} diff --git a/service/manager/repository/sqlite/repository.go b/service/manager/repository/sqlite/repository.go index ab15174a..7857b3ef 100644 --- a/service/manager/repository/sqlite/repository.go +++ b/service/manager/repository/sqlite/repository.go @@ -9,8 +9,8 @@ import ( "github.com/golang-migrate/migrate/v4" "github.com/huandu/go-sqlbuilder" - "github.com/sagernet/sing-box/common/byteformats" "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing/common/byteformats" _ "modernc.org/sqlite" ) @@ -33,6 +33,13 @@ func NewSQLiteRepository(ctx context.Context, dsn string) (*SQLiteRepository, er return &SQLiteRepository{db: db, ctx: ctx}, nil } +func notFoundErr(err error) error { + if errors.Is(err, sql.ErrNoRows) { + return constant.ErrNotFound + } + return err +} + func (r *SQLiteRepository) CreateSquad(squad constant.SquadCreate) (constant.Squad, error) { var s constant.Squad now := time.Now() @@ -134,7 +141,7 @@ func (r *SQLiteRepository) GetSquad(id int) (constant.Squad, error) { &s.CreatedAt, &s.UpdatedAt, ) - return s, err + return s, notFoundErr(err) } func (r *SQLiteRepository) UpdateSquad(id int, squad constant.SquadUpdate) (constant.Squad, error) { @@ -446,13 +453,9 @@ func (r *SQLiteRepository) GetNode(uuid string) (constant.Node, error) { &n.CreatedAt, &n.UpdatedAt, ) - if errors.Is(err, sql.ErrNoRows) { - return n, constant.ErrNotFound - } n.SquadIDs = []int(squadIDs) - return n, err + return n, notFoundErr(err) } - func (r *SQLiteRepository) UpdateNode(uuid string, node constant.NodeUpdate) (constant.Node, error) { var n constant.Node err := r.db.QueryRowContext( @@ -693,7 +696,7 @@ func (r *SQLiteRepository) GetUser(id int) (constant.User, error) { &u.UpdatedAt, ) u.SquadIDs = []int(squadIDs) - return u, err + return u, notFoundErr(err) } func (r *SQLiteRepository) UpdateUser(id int, user constant.UserUpdate) (constant.User, error) { @@ -975,7 +978,7 @@ func (r *SQLiteRepository) GetConnectionLimiter(id int) (constant.ConnectionLimi &cl.UpdatedAt, ) cl.SquadIDs = []int(squadIDs) - return cl, err + return cl, notFoundErr(err) } func (r *SQLiteRepository) UpdateConnectionLimiter(id int, limiter constant.ConnectionLimiterUpdate) (constant.ConnectionLimiter, error) { @@ -1280,7 +1283,7 @@ func (r *SQLiteRepository) GetBandwidthLimiter(id int) (constant.BandwidthLimite ) bl.SquadIDs = []int(squadIDs) bl.FlowKeys = []string(flowKeys) - return bl, err + return bl, notFoundErr(err) } func (r *SQLiteRepository) UpdateBandwidthLimiter(id int, limiter constant.BandwidthLimiterUpdate) (constant.BandwidthLimiter, error) { @@ -1603,7 +1606,7 @@ func (r *SQLiteRepository) GetTrafficLimiter(id int) (constant.TrafficLimiter, e &tl.UpdatedAt, ) tl.SquadIDs = []int(squadIDs) - return tl, err + return tl, notFoundErr(err) } func (r *SQLiteRepository) UpdateTrafficLimiter(id int, limiter constant.TrafficLimiterUpdate) (constant.TrafficLimiter, error) { @@ -1943,7 +1946,7 @@ func (r *SQLiteRepository) GetRateLimiter(id int) (constant.RateLimiter, error) &rl.UpdatedAt, ) rl.SquadIDs = []int(squadIDs) - return rl, err + return rl, notFoundErr(err) } func (r *SQLiteRepository) UpdateRateLimiter(id int, limiter constant.RateLimiterUpdate) (constant.RateLimiter, error) { @@ -2048,8 +2051,8 @@ func init() { "created_at_end": LessThanFilter("created_at"), "updated_at_start": GreaterThanFilter("updated_at"), "updated_at_end": LessThanFilter("updated_at"), - "sort_asc": SortAscFilter(), - "sort_desc": SortDescFilter(), + "sort_asc": SortAscFilter([]string{"id", "name", "created_at", "updated_at"}), + "sort_desc": SortDescFilter([]string{"id", "name", "created_at", "updated_at"}), "offset": OffsetFilter(), "limit": LimitFilter(), } @@ -2057,8 +2060,8 @@ func init() { "uuid": EqualFilter("uuid"), "pk": EqualFilter("uuid"), "name": EqualFilter("name"), - "squad_id_in": ExistsAndWhereInFilter( - sqlbuilder.SQLite.NewSelectBuilder(). + "squad_id_in": ExistsAndWhereInFilter(func() *sqlbuilder.SelectBuilder { + return sqlbuilder.SQLite.NewSelectBuilder(). Select( "squad_id", ). @@ -2067,23 +2070,22 @@ func init() { ). From( "node_to_squad", - ), - "node_to_squad.squad_id", - ), + ) + }, "node_to_squad.squad_id"), "created_at_start": GreaterThanFilter("created_at"), "created_at_end": LessThanFilter("created_at"), "updated_at_start": GreaterThanFilter("updated_at"), "updated_at_end": LessThanFilter("updated_at"), - "sort_asc": SortAscFilter(), - "sort_desc": SortDescFilter(), + "sort_asc": SortAscFilter([]string{"uuid", "name", "created_at", "updated_at"}), + "sort_desc": SortDescFilter([]string{"uuid", "name", "created_at", "updated_at"}), "offset": OffsetFilter(), "limit": LimitFilter(), } userFilters = map[string]Filter{ "id": EqualFilter("id"), "pk": EqualFilter("id"), - "squad_id_in": ExistsAndWhereInFilter( - sqlbuilder.SQLite.NewSelectBuilder(). + "squad_id_in": ExistsAndWhereInFilter(func() *sqlbuilder.SelectBuilder { + return sqlbuilder.SQLite.NewSelectBuilder(). Select( "squad_id", ). @@ -2092,26 +2094,24 @@ func init() { ). From( "user_to_squad", - ), - "user_to_squad.squad_id", - ), + ) + }, "user_to_squad.squad_id"), "username": EqualFilter("username"), "inbound": EqualFilter("inbound"), - "type": EqualFilter("type"), "created_at_start": GreaterThanFilter("created_at"), "created_at_end": LessThanFilter("created_at"), "updated_at_start": GreaterThanFilter("updated_at"), "updated_at_end": LessThanFilter("updated_at"), - "sort_asc": SortAscFilter(), - "sort_desc": SortDescFilter(), + "sort_asc": SortAscFilter([]string{"id", "username", "inbound", "type", "created_at", "updated_at"}), + "sort_desc": SortDescFilter([]string{"id", "username", "inbound", "type", "created_at", "updated_at"}), "offset": OffsetFilter(), "limit": LimitFilter(), } connectionLimiterFilters = map[string]Filter{ "id": EqualFilter("id"), "pk": EqualFilter("id"), - "squad_id_in": ExistsAndWhereInFilter( - sqlbuilder.SQLite.NewSelectBuilder(). + "squad_id_in": ExistsAndWhereInFilter(func() *sqlbuilder.SelectBuilder { + return sqlbuilder.SQLite.NewSelectBuilder(). Select( "squad_id", ). @@ -2120,9 +2120,8 @@ func init() { ). From( "connection_limiter_to_squad", - ), - "connection_limiter_to_squad.squad_id", - ), + ) + }, "connection_limiter_to_squad.squad_id"), "strategy": EqualFilter("strategy"), "username": EqualFilter("username"), "outbound": EqualFilter("outbound"), @@ -2132,16 +2131,16 @@ func init() { "created_at_end": LessThanFilter("created_at"), "updated_at_start": GreaterThanFilter("updated_at"), "updated_at_end": LessThanFilter("updated_at"), - "sort_asc": SortAscFilter(), - "sort_desc": SortDescFilter(), + "sort_asc": SortAscFilter([]string{"id", "username", "outbound", "strategy", "connection_type", "lock_type", "count", "created_at", "updated_at"}), + "sort_desc": SortDescFilter([]string{"id", "username", "outbound", "strategy", "connection_type", "lock_type", "count", "created_at", "updated_at"}), "offset": OffsetFilter(), "limit": LimitFilter(), } bandwidthLimiterFilters = map[string]Filter{ "id": EqualFilter("id"), "pk": EqualFilter("id"), - "squad_id_in": ExistsAndWhereInFilter( - sqlbuilder.SQLite.NewSelectBuilder(). + "squad_id_in": ExistsAndWhereInFilter(func() *sqlbuilder.SelectBuilder { + return sqlbuilder.SQLite.NewSelectBuilder(). Select( "squad_id", ). @@ -2150,31 +2149,31 @@ func init() { ). From( "bandwidth_limiter_to_squad", - ), - "bandwidth_limiter_to_squad.squad_id", - ), + ) + }, "bandwidth_limiter_to_squad.squad_id"), "strategy": EqualFilter("strategy"), "mode": EqualFilter("mode"), - "type": EqualFilter("type"), "username": EqualFilter("username"), - "down_start": SpeedGreaterEqualThanFilter("raw_down"), - "down_end": SpeedLessEqualThanFilter("raw_down"), - "up_start": SpeedGreaterEqualThanFilter("raw_up"), - "up_end": SpeedLessEqualThanFilter("raw_up"), "created_at_start": GreaterThanFilter("created_at"), "created_at_end": LessThanFilter("created_at"), "updated_at_start": GreaterThanFilter("updated_at"), "updated_at_end": LessThanFilter("updated_at"), - "sort_asc": ReplacedSortAscFilter(map[string]string{"down": "raw_down", "up": "raw_up"}), - "sort_desc": ReplacedSortDescFilter(map[string]string{"down": "raw_down", "up": "raw_up"}), - "offset": OffsetFilter(), - "limit": LimitFilter(), + "sort_asc": ReplacedSortAscFilter( + map[string]string{"speed": "raw_speed"}, + []string{"id", "username", "outbound", "strategy", "mode", "raw_speed", "created_at", "updated_at"}, + ), + "sort_desc": ReplacedSortDescFilter( + map[string]string{"speed": "raw_speed"}, + []string{"id", "username", "outbound", "strategy", "mode", "raw_speed", "created_at", "updated_at"}, + ), + "offset": OffsetFilter(), + "limit": LimitFilter(), } trafficLimiterFilters = map[string]Filter{ "id": EqualFilter("id"), "pk": EqualFilter("id"), - "squad_id_in": ExistsAndWhereInFilter( - sqlbuilder.SQLite.NewSelectBuilder(). + "squad_id_in": ExistsAndWhereInFilter(func() *sqlbuilder.SelectBuilder { + return sqlbuilder.SQLite.NewSelectBuilder(). Select( "squad_id", ). @@ -2183,31 +2182,36 @@ func init() { ). From( "traffic_limiter_to_squad", - ), - "traffic_limiter_to_squad.squad_id", - ), + ) + }, "traffic_limiter_to_squad.squad_id"), "username": EqualFilter("username"), "outbound": EqualFilter("outbound"), "strategy": EqualFilter("strategy"), "mode": EqualFilter("mode"), - "used_start": SpeedGreaterEqualThanFilter("raw_used"), - "used_end": SpeedLessEqualThanFilter("raw_used"), + "used_start": SpeedGreaterEqualThanFilter("raw_used"), + "used_end": SpeedLessEqualThanFilter("raw_used"), "quota_start": SpeedGreaterEqualThanFilter("raw_quota"), "quota_end": SpeedLessEqualThanFilter("raw_quota"), "created_at_start": GreaterThanFilter("created_at"), "created_at_end": LessThanFilter("created_at"), "updated_at_start": GreaterThanFilter("updated_at"), "updated_at_end": LessThanFilter("updated_at"), - "sort_asc": ReplacedSortAscFilter(map[string]string{"used": "raw_used", "quota": "raw_quota"}), - "sort_desc": ReplacedSortDescFilter(map[string]string{"used": "raw_used", "quota": "raw_quota"}), - "offset": OffsetFilter(), - "limit": LimitFilter(), + "sort_asc": ReplacedSortAscFilter( + map[string]string{"used": "raw_used", "quota": "raw_quota"}, + []string{"id", "username", "outbound", "strategy", "mode", "raw_used", "raw_quota", "created_at", "updated_at"}, + ), + "sort_desc": ReplacedSortDescFilter( + map[string]string{"used": "raw_used", "quota": "raw_quota"}, + []string{"id", "username", "outbound", "strategy", "mode", "raw_used", "raw_quota", "created_at", "updated_at"}, + ), + "offset": OffsetFilter(), + "limit": LimitFilter(), } rateLimiterFilters = map[string]Filter{ "id": EqualFilter("id"), "pk": EqualFilter("id"), - "squad_id_in": ExistsAndWhereInFilter( - sqlbuilder.SQLite.NewSelectBuilder(). + "squad_id_in": ExistsAndWhereInFilter(func() *sqlbuilder.SelectBuilder { + return sqlbuilder.SQLite.NewSelectBuilder(). Select( "squad_id", ). @@ -2216,9 +2220,8 @@ func init() { ). From( "rate_limiter_to_squad", - ), - "rate_limiter_to_squad.squad_id", - ), + ) + }, "rate_limiter_to_squad.squad_id"), "strategy": EqualFilter("strategy"), "username": EqualFilter("username"), "outbound": EqualFilter("outbound"), @@ -2230,8 +2233,8 @@ func init() { "created_at_end": LessThanFilter("created_at"), "updated_at_start": GreaterThanFilter("updated_at"), "updated_at_end": LessThanFilter("updated_at"), - "sort_asc": SortAscFilter(), - "sort_desc": SortDescFilter(), + "sort_asc": SortAscFilter([]string{"id", "username", "outbound", "strategy", "connection_type", "count", "interval", "created_at", "updated_at"}), + "sort_desc": SortDescFilter([]string{"id", "username", "outbound", "strategy", "connection_type", "count", "interval", "created_at", "updated_at"}), "offset": OffsetFilter(), "limit": LimitFilter(), } diff --git a/service/manager/service.go b/service/manager/service.go index 31de5897..278df0d2 100644 --- a/service/manager/service.go +++ b/service/manager/service.go @@ -20,6 +20,10 @@ import ( "github.com/sagernet/sing-box/service/manager/repository/sqlite" E "github.com/sagernet/sing/common/exceptions" "github.com/shtorm-7/go-cache/v2" + wpconstant "github.com/shtorm-7/workerpool/constant" + "github.com/shtorm-7/workerpool/pool" + "github.com/shtorm-7/workerpool/tools" + "github.com/shtorm-7/workerpool/worker" ) func RegisterService(registry *boxService.Registry) { @@ -28,16 +32,19 @@ func RegisterService(registry *boxService.Registry) { type Service struct { boxService.Adapter - ctx context.Context - logger log.ContextLogger - repository constant.Repository - nodes map[string]constant.ConnectedNode + ctx context.Context + logger log.ContextLogger + repository constant.Repository + nodes map[string]constant.ConnectedNode limiterLocks map[int]map[string]*cache.Cache[string, struct{}] trafficUsage map[int]*TrafficUsage defaultValidator *validator.Validate + broadcastQueue wpconstant.Queue + broadcastPool wpconstant.Pool + mtx sync.RWMutex connLockMtx sync.Mutex trafficMtx sync.Mutex @@ -106,6 +113,13 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio defaultValidator.RegisterStructValidation(func(sl validator.StructLevel) { validateRateLimiterInterval(sl, sl.Current().Interface().(constant.RateLimiterUpdate).Interval) }, constant.RateLimiterUpdate{}) + broadcastQueue := make(wpconstant.Queue) + broadcastWorkers := make([]wpconstant.WorkerFactory, 16) + for i := range broadcastWorkers { + broadcastWorkers[i] = worker.NewWorkerFactory(broadcastQueue) + } + broadcastPool := pool.NewPool(broadcastWorkers) + broadcastPool.Start() service := &Service{ Adapter: boxService.NewAdapter(C.TypeManager, tag), ctx: ctx, @@ -115,6 +129,8 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio limiterLocks: make(map[int]map[string]*cache.Cache[string, struct{}]), trafficUsage: make(map[int]*TrafficUsage), defaultValidator: defaultValidator, + broadcastQueue: broadcastQueue, + broadcastPool: broadcastPool, } limiters, err := service.repository.GetTrafficLimiters(map[string][]string{}) if err != nil { @@ -320,11 +336,9 @@ func (s *Service) CreateUser(user constant.UserCreate) (constant.User, error) { s.closeAllNodes() return createdUser, err } - for _, node := range nodes { - if node, ok := s.nodes[node.UUID]; ok { - node.UpdateUser(createdUser) - } - } + s.dispatchToNodes(nodes, func(node constant.ConnectedNode) { + node.UpdateUser(createdUser) + }) return createdUser, nil } @@ -343,6 +357,10 @@ func (s *Service) GetUser(id int) (constant.User, error) { func (s *Service) UpdateUser(id int, user constant.UserUpdate) (constant.User, error) { s.mtx.Lock() defer s.mtx.Unlock() + err := s.defaultValidator.Struct(user) + if err != nil { + return constant.User{}, err + } updatedUser, err := s.repository.UpdateUser(id, user) if err != nil { return updatedUser, err @@ -354,11 +372,9 @@ func (s *Service) UpdateUser(id int, user constant.UserUpdate) (constant.User, e s.closeAllNodes() return updatedUser, err } - for _, node := range nodes { - if node, ok := s.nodes[node.UUID]; ok { - node.UpdateUser(updatedUser) - } - } + s.dispatchToNodes(nodes, func(node constant.ConnectedNode) { + node.UpdateUser(updatedUser) + }) return updatedUser, nil } @@ -376,11 +392,9 @@ func (s *Service) DeleteUser(id int) (constant.User, error) { s.closeAllNodes() return deletedUser, err } - for _, node := range nodes { - if node, ok := s.nodes[node.UUID]; ok { - node.DeleteUser(deletedUser) - } - } + s.dispatchToNodes(nodes, func(node constant.ConnectedNode) { + node.DeleteUser(deletedUser) + }) return deletedUser, nil } @@ -402,11 +416,9 @@ func (s *Service) CreateBandwidthLimiter(limiter constant.BandwidthLimiterCreate s.closeAllNodes() return createdLimiter, err } - for _, node := range nodes { - if node, ok := s.nodes[node.UUID]; ok { - node.UpdateBandwidthLimiter(createdLimiter) - } - } + s.dispatchToNodes(nodes, func(node constant.ConnectedNode) { + node.UpdateBandwidthLimiter(createdLimiter) + }) return createdLimiter, nil } @@ -440,11 +452,9 @@ func (s *Service) UpdateBandwidthLimiter(id int, limiter constant.BandwidthLimit s.closeAllNodes() return updatedLimiter, err } - for _, node := range nodes { - if node, ok := s.nodes[node.UUID]; ok { - node.UpdateBandwidthLimiter(updatedLimiter) - } - } + s.dispatchToNodes(nodes, func(node constant.ConnectedNode) { + node.UpdateBandwidthLimiter(updatedLimiter) + }) return updatedLimiter, nil } @@ -462,11 +472,9 @@ func (s *Service) DeleteBandwidthLimiter(id int) (constant.BandwidthLimiter, err s.closeAllNodes() return deletedLimiter, err } - for _, node := range nodes { - if node, ok := s.nodes[node.UUID]; ok { - node.DeleteBandwidthLimiter(deletedLimiter) - } - } + s.dispatchToNodes(nodes, func(node constant.ConnectedNode) { + node.DeleteBandwidthLimiter(deletedLimiter) + }) return deletedLimiter, nil } @@ -494,11 +502,9 @@ func (s *Service) CreateTrafficLimiter(limiter constant.TrafficLimiterCreate) (c s.closeAllNodes() return createdLimiter, err } - for _, node := range nodes { - if node, ok := s.nodes[node.UUID]; ok { - node.UpdateTrafficLimiter(createdLimiter) - } - } + s.dispatchToNodes(nodes, func(node constant.ConnectedNode) { + node.UpdateTrafficLimiter(createdLimiter) + }) return createdLimiter, nil } @@ -538,11 +544,9 @@ func (s *Service) UpdateTrafficLimiter(id int, limiter constant.TrafficLimiterUp s.closeAllNodes() return updatedLimiter, err } - for _, node := range nodes { - if node, ok := s.nodes[node.UUID]; ok { - node.UpdateTrafficLimiter(updatedLimiter) - } - } + s.dispatchToNodes(nodes, func(node constant.ConnectedNode) { + node.UpdateTrafficLimiter(updatedLimiter) + }) return updatedLimiter, nil } @@ -566,11 +570,9 @@ func (s *Service) UpdateTrafficLimiterUsed(id int, used uint64) (constant.Traffi s.closeAllNodes() return updatedLimiter, err } - for _, node := range nodes { - if node, ok := s.nodes[node.UUID]; ok { - node.UpdateTrafficLimiter(updatedLimiter) - } - } + s.dispatchToNodes(nodes, func(node constant.ConnectedNode) { + node.UpdateTrafficLimiter(updatedLimiter) + }) return updatedLimiter, nil } @@ -591,11 +593,9 @@ func (s *Service) DeleteTrafficLimiter(id int) (constant.TrafficLimiter, error) s.closeAllNodes() return deletedLimiter, err } - for _, node := range nodes { - if node, ok := s.nodes[node.UUID]; ok { - node.DeleteTrafficLimiter(deletedLimiter) - } - } + s.dispatchToNodes(nodes, func(node constant.ConnectedNode) { + node.DeleteTrafficLimiter(deletedLimiter) + }) return deletedLimiter, nil } @@ -617,11 +617,9 @@ func (s *Service) CreateConnectionLimiter(limiter constant.ConnectionLimiterCrea s.closeAllNodes() return createdLimiter, err } - for _, node := range nodes { - if node, ok := s.nodes[node.UUID]; ok { - node.UpdateConnectionLimiter(createdLimiter) - } - } + s.dispatchToNodes(nodes, func(node constant.ConnectedNode) { + node.UpdateConnectionLimiter(createdLimiter) + }) return createdLimiter, nil } @@ -655,11 +653,9 @@ func (s *Service) UpdateConnectionLimiter(id int, limiter constant.ConnectionLim s.closeAllNodes() return updatedLimiter, err } - for _, node := range nodes { - if node, ok := s.nodes[node.UUID]; ok { - node.UpdateConnectionLimiter(updatedLimiter) - } - } + s.dispatchToNodes(nodes, func(node constant.ConnectedNode) { + node.UpdateConnectionLimiter(updatedLimiter) + }) if limiter.LockType != "manager" { s.connLockMtx.Lock() defer s.connLockMtx.Unlock() @@ -682,11 +678,9 @@ func (s *Service) DeleteConnectionLimiter(id int) (constant.ConnectionLimiter, e s.closeAllNodes() return deletedLimiter, err } - for _, node := range nodes { - if node, ok := s.nodes[node.UUID]; ok { - node.DeleteConnectionLimiter(deletedLimiter) - } - } + s.dispatchToNodes(nodes, func(node constant.ConnectedNode) { + node.DeleteConnectionLimiter(deletedLimiter) + }) if deletedLimiter.LockType == "manager" { s.connLockMtx.Lock() defer s.connLockMtx.Unlock() @@ -713,11 +707,9 @@ func (s *Service) CreateRateLimiter(limiter constant.RateLimiterCreate) (constan s.closeAllNodes() return createdLimiter, err } - for _, node := range nodes { - if node, ok := s.nodes[node.UUID]; ok { - node.UpdateRateLimiter(createdLimiter) - } - } + s.dispatchToNodes(nodes, func(node constant.ConnectedNode) { + node.UpdateRateLimiter(createdLimiter) + }) return createdLimiter, nil } @@ -751,11 +743,9 @@ func (s *Service) UpdateRateLimiter(id int, limiter constant.RateLimiterUpdate) s.closeAllNodes() return updatedLimiter, err } - for _, node := range nodes { - if node, ok := s.nodes[node.UUID]; ok { - node.UpdateRateLimiter(updatedLimiter) - } - } + s.dispatchToNodes(nodes, func(node constant.ConnectedNode) { + node.UpdateRateLimiter(updatedLimiter) + }) return updatedLimiter, nil } @@ -773,11 +763,9 @@ func (s *Service) DeleteRateLimiter(id int) (constant.RateLimiter, error) { s.closeAllNodes() return deletedLimiter, err } - for _, node := range nodes { - if node, ok := s.nodes[node.UUID]; ok { - node.DeleteRateLimiter(deletedLimiter) - } - } + s.dispatchToNodes(nodes, func(node constant.ConnectedNode) { + node.DeleteRateLimiter(deletedLimiter) + }) return deletedLimiter, nil } @@ -922,6 +910,7 @@ func (s *Service) Start(stage adapter.StartStage) error { } func (s *Service) Close() error { + s.broadcastPool.Stop() return nil } @@ -936,6 +925,22 @@ func (s *Service) closeAllNodes() { } } +func (s *Service) dispatchToNodes(nodes []constant.Node, fn func(node constant.ConnectedNode)) { + awaits := make([]<-chan struct{}, 0, len(nodes)) + for _, node := range nodes { + connectedNode, ok := s.nodes[node.UUID] + if !ok { + continue + } + awaits = append(awaits, tools.Await(s.broadcastQueue, func() { + fn(connectedNode) + })) + } + for _, await := range awaits { + <-await + } +} + func convertIntSliceToStringSlice(values []int) []string { result := make([]string, len(values)) for i, v := range values { diff --git a/service/node/inbound/anytls.go b/service/node/inbound/anytls.go new file mode 100644 index 00000000..30efad38 --- /dev/null +++ b/service/node/inbound/anytls.go @@ -0,0 +1,89 @@ +package inbound + +import ( + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/anytls" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" +) + +type AnyTLSManager struct { + inbounds map[string]*AnyTLSUserManager + + mtx sync.Mutex +} + +func NewAnyTLSManager() *AnyTLSManager { + return &AnyTLSManager{ + inbounds: make(map[string]*AnyTLSUserManager), + } +} + +func (m *AnyTLSManager) AddUserManager(inbound adapter.Inbound) error { + m.mtx.Lock() + defer m.mtx.Unlock() + m.inbounds[inbound.Tag()] = &AnyTLSUserManager{ + inbound: inbound.(*anytls.Inbound), + usersMap: make(map[string]option.AnyTLSUser), + } + return nil +} + +func (m *AnyTLSManager) GetUserManager(tag string) (constant.UserManager, bool) { + m.mtx.Lock() + defer m.mtx.Unlock() + inbound, ok := m.inbounds[tag] + return inbound, ok +} + +func (m *AnyTLSManager) GetUserManagerTags() []string { + m.mtx.Lock() + defer m.mtx.Unlock() + tags := make([]string, 0, len(m.inbounds)) + for tag := range m.inbounds { + tags = append(tags, tag) + } + return tags +} + +type AnyTLSUserManager struct { + inbound *anytls.Inbound + usersMap map[string]option.AnyTLSUser + + mtx sync.Mutex +} + +func (i *AnyTLSUserManager) postUpdate() { + users := make([]option.AnyTLSUser, 0, len(i.usersMap)) + for _, user := range i.usersMap { + users = append(users, user) + } + i.inbound.UpdateUsers(users) +} + +func (i *AnyTLSUserManager) UpdateUser(user CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + i.usersMap[user.Username] = option.AnyTLSUser{Name: user.Username, Password: user.Password} + i.postUpdate() +} + +func (i *AnyTLSUserManager) UpdateUsers(users []CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.usersMap) + for _, user := range users { + i.usersMap[user.Username] = option.AnyTLSUser{Name: user.Username, Password: user.Password} + } + i.postUpdate() +} + +func (i *AnyTLSUserManager) DeleteUser(username string) { + i.mtx.Lock() + defer i.mtx.Unlock() + delete(i.usersMap, username) + i.postUpdate() +} diff --git a/service/node/inbound/http.go b/service/node/inbound/http.go new file mode 100644 index 00000000..78eb6f5c --- /dev/null +++ b/service/node/inbound/http.go @@ -0,0 +1,89 @@ +package inbound + +import ( + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/protocol/http" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" + "github.com/sagernet/sing/common/auth" +) + +type HTTPManager struct { + inbounds map[string]*HTTPUserManager + + mtx sync.Mutex +} + +func NewHTTPManager() *HTTPManager { + return &HTTPManager{ + inbounds: make(map[string]*HTTPUserManager), + } +} + +func (m *HTTPManager) AddUserManager(inbound adapter.Inbound) error { + m.mtx.Lock() + defer m.mtx.Unlock() + m.inbounds[inbound.Tag()] = &HTTPUserManager{ + inbound: inbound.(*http.Inbound), + usersMap: make(map[string]auth.User), + } + return nil +} + +func (m *HTTPManager) GetUserManager(tag string) (constant.UserManager, bool) { + m.mtx.Lock() + defer m.mtx.Unlock() + inbound, ok := m.inbounds[tag] + return inbound, ok +} + +func (m *HTTPManager) GetUserManagerTags() []string { + m.mtx.Lock() + defer m.mtx.Unlock() + tags := make([]string, 0, len(m.inbounds)) + for tag := range m.inbounds { + tags = append(tags, tag) + } + return tags +} + +type HTTPUserManager struct { + inbound *http.Inbound + usersMap map[string]auth.User + + mtx sync.Mutex +} + +func (i *HTTPUserManager) postUpdate() { + users := make([]auth.User, 0, len(i.usersMap)) + for _, user := range i.usersMap { + users = append(users, user) + } + i.inbound.UpdateUsers(users) +} + +func (i *HTTPUserManager) UpdateUser(user CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + i.usersMap[user.Username] = auth.User{Username: user.Username, Password: user.Password} + i.postUpdate() +} + +func (i *HTTPUserManager) UpdateUsers(users []CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.usersMap) + for _, user := range users { + i.usersMap[user.Username] = auth.User{Username: user.Username, Password: user.Password} + } + i.postUpdate() +} + +func (i *HTTPUserManager) DeleteUser(username string) { + i.mtx.Lock() + defer i.mtx.Unlock() + delete(i.usersMap, username) + i.postUpdate() +} diff --git a/service/node/inbound/hysteria.go b/service/node/inbound/hysteria.go index b3d46bc2..c975e033 100644 --- a/service/node/inbound/hysteria.go +++ b/service/node/inbound/hysteria.go @@ -11,8 +11,9 @@ import ( ) type HysteriaManager struct { - access sync.Mutex inbounds map[string]*HysteriaUserManager + + mtx sync.Mutex } func NewHysteriaManager() *HysteriaManager { @@ -22,8 +23,8 @@ func NewHysteriaManager() *HysteriaManager { } func (m *HysteriaManager) AddUserManager(inbound adapter.Inbound) error { - m.access.Lock() - defer m.access.Unlock() + m.mtx.Lock() + defer m.mtx.Unlock() m.inbounds[inbound.Tag()] = &HysteriaUserManager{ inbound: inbound.(*hysteria.Inbound), usersMap: make(map[string]option.HysteriaUser), @@ -32,15 +33,15 @@ func (m *HysteriaManager) AddUserManager(inbound adapter.Inbound) error { } func (m *HysteriaManager) GetUserManager(tag string) (constant.UserManager, bool) { - m.access.Lock() - defer m.access.Unlock() + m.mtx.Lock() + defer m.mtx.Unlock() inbound, ok := m.inbounds[tag] return inbound, ok } func (m *HysteriaManager) GetUserManagerTags() []string { - m.access.Lock() - defer m.access.Unlock() + m.mtx.Lock() + defer m.mtx.Unlock() tags := make([]string, 0, len(m.inbounds)) for tag := range m.inbounds { tags = append(tags, tag) diff --git a/service/node/inbound/hysteria2.go b/service/node/inbound/hysteria2.go index 979e7715..4bd4342a 100644 --- a/service/node/inbound/hysteria2.go +++ b/service/node/inbound/hysteria2.go @@ -11,8 +11,9 @@ import ( ) type Hysteria2Manager struct { - access sync.Mutex inbounds map[string]*Hysteria2UserManager + + mtx sync.Mutex } func NewHysteria2Manager() *Hysteria2Manager { @@ -22,8 +23,8 @@ func NewHysteria2Manager() *Hysteria2Manager { } func (m *Hysteria2Manager) AddUserManager(inbound adapter.Inbound) error { - m.access.Lock() - defer m.access.Unlock() + m.mtx.Lock() + defer m.mtx.Unlock() m.inbounds[inbound.Tag()] = &Hysteria2UserManager{ inbound: inbound.(*hysteria2.Inbound), usersMap: make(map[string]option.Hysteria2User), @@ -32,15 +33,15 @@ func (m *Hysteria2Manager) AddUserManager(inbound adapter.Inbound) error { } func (m *Hysteria2Manager) GetUserManager(tag string) (constant.UserManager, bool) { - m.access.Lock() - defer m.access.Unlock() + m.mtx.Lock() + defer m.mtx.Unlock() inbound, ok := m.inbounds[tag] return inbound, ok } func (m *Hysteria2Manager) GetUserManagerTags() []string { - m.access.Lock() - defer m.access.Unlock() + m.mtx.Lock() + defer m.mtx.Unlock() tags := make([]string, 0, len(m.inbounds)) for tag := range m.inbounds { tags = append(tags, tag) diff --git a/service/node/inbound/mixed.go b/service/node/inbound/mixed.go new file mode 100644 index 00000000..1cf1df1e --- /dev/null +++ b/service/node/inbound/mixed.go @@ -0,0 +1,89 @@ +package inbound + +import ( + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/protocol/mixed" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" + "github.com/sagernet/sing/common/auth" +) + +type MixedManager struct { + inbounds map[string]*MixedUserManager + + mtx sync.Mutex +} + +func NewMixedManager() *MixedManager { + return &MixedManager{ + inbounds: make(map[string]*MixedUserManager), + } +} + +func (m *MixedManager) AddUserManager(inbound adapter.Inbound) error { + m.mtx.Lock() + defer m.mtx.Unlock() + m.inbounds[inbound.Tag()] = &MixedUserManager{ + inbound: inbound.(*mixed.Inbound), + usersMap: make(map[string]auth.User), + } + return nil +} + +func (m *MixedManager) GetUserManager(tag string) (constant.UserManager, bool) { + m.mtx.Lock() + defer m.mtx.Unlock() + inbound, ok := m.inbounds[tag] + return inbound, ok +} + +func (m *MixedManager) GetUserManagerTags() []string { + m.mtx.Lock() + defer m.mtx.Unlock() + tags := make([]string, 0, len(m.inbounds)) + for tag := range m.inbounds { + tags = append(tags, tag) + } + return tags +} + +type MixedUserManager struct { + inbound *mixed.Inbound + usersMap map[string]auth.User + + mtx sync.Mutex +} + +func (i *MixedUserManager) postUpdate() { + users := make([]auth.User, 0, len(i.usersMap)) + for _, user := range i.usersMap { + users = append(users, user) + } + i.inbound.UpdateUsers(users) +} + +func (i *MixedUserManager) UpdateUser(user CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + i.usersMap[user.Username] = auth.User{Username: user.Username, Password: user.Password} + i.postUpdate() +} + +func (i *MixedUserManager) UpdateUsers(users []CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.usersMap) + for _, user := range users { + i.usersMap[user.Username] = auth.User{Username: user.Username, Password: user.Password} + } + i.postUpdate() +} + +func (i *MixedUserManager) DeleteUser(username string) { + i.mtx.Lock() + defer i.mtx.Unlock() + delete(i.usersMap, username) + i.postUpdate() +} diff --git a/service/node/inbound/mtproxy.go b/service/node/inbound/mtproxy.go index 2f589229..ee826dd2 100644 --- a/service/node/inbound/mtproxy.go +++ b/service/node/inbound/mtproxy.go @@ -11,8 +11,9 @@ import ( ) type MTProxyManager struct { - access sync.Mutex inbounds map[string]*MTProxyUserManager + + mtx sync.Mutex } func NewMTProxyManager() *MTProxyManager { @@ -22,8 +23,8 @@ func NewMTProxyManager() *MTProxyManager { } func (m *MTProxyManager) AddUserManager(inbound adapter.Inbound) error { - m.access.Lock() - defer m.access.Unlock() + m.mtx.Lock() + defer m.mtx.Unlock() m.inbounds[inbound.Tag()] = &MTProxyUserManager{ inbound: inbound.(*mtproxy.Inbound), usersMap: make(map[string]option.MTProxyUser), @@ -32,15 +33,15 @@ func (m *MTProxyManager) AddUserManager(inbound adapter.Inbound) error { } func (m *MTProxyManager) GetUserManager(tag string) (constant.UserManager, bool) { - m.access.Lock() - defer m.access.Unlock() + m.mtx.Lock() + defer m.mtx.Unlock() inbound, ok := m.inbounds[tag] return inbound, ok } func (m *MTProxyManager) GetUserManagerTags() []string { - m.access.Lock() - defer m.access.Unlock() + m.mtx.Lock() + defer m.mtx.Unlock() tags := make([]string, 0, len(m.inbounds)) for tag := range m.inbounds { tags = append(tags, tag) diff --git a/service/node/inbound/naive.go b/service/node/inbound/naive.go new file mode 100644 index 00000000..5dbadca3 --- /dev/null +++ b/service/node/inbound/naive.go @@ -0,0 +1,89 @@ +package inbound + +import ( + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/protocol/naive" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" + "github.com/sagernet/sing/common/auth" +) + +type NaiveManager struct { + inbounds map[string]*NaiveUserManager + + mtx sync.Mutex +} + +func NewNaiveManager() *NaiveManager { + return &NaiveManager{ + inbounds: make(map[string]*NaiveUserManager), + } +} + +func (m *NaiveManager) AddUserManager(inbound adapter.Inbound) error { + m.mtx.Lock() + defer m.mtx.Unlock() + m.inbounds[inbound.Tag()] = &NaiveUserManager{ + inbound: inbound.(*naive.Inbound), + usersMap: make(map[string]auth.User), + } + return nil +} + +func (m *NaiveManager) GetUserManager(tag string) (constant.UserManager, bool) { + m.mtx.Lock() + defer m.mtx.Unlock() + inbound, ok := m.inbounds[tag] + return inbound, ok +} + +func (m *NaiveManager) GetUserManagerTags() []string { + m.mtx.Lock() + defer m.mtx.Unlock() + tags := make([]string, 0, len(m.inbounds)) + for tag := range m.inbounds { + tags = append(tags, tag) + } + return tags +} + +type NaiveUserManager struct { + inbound *naive.Inbound + usersMap map[string]auth.User + + mtx sync.Mutex +} + +func (i *NaiveUserManager) postUpdate() { + users := make([]auth.User, 0, len(i.usersMap)) + for _, user := range i.usersMap { + users = append(users, user) + } + i.inbound.UpdateUsers(users) +} + +func (i *NaiveUserManager) UpdateUser(user CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + i.usersMap[user.Username] = auth.User{Username: user.Username, Password: user.Password} + i.postUpdate() +} + +func (i *NaiveUserManager) UpdateUsers(users []CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.usersMap) + for _, user := range users { + i.usersMap[user.Username] = auth.User{Username: user.Username, Password: user.Password} + } + i.postUpdate() +} + +func (i *NaiveUserManager) DeleteUser(username string) { + i.mtx.Lock() + defer i.mtx.Unlock() + delete(i.usersMap, username) + i.postUpdate() +} diff --git a/service/node/inbound/socks.go b/service/node/inbound/socks.go new file mode 100644 index 00000000..8c2c4f48 --- /dev/null +++ b/service/node/inbound/socks.go @@ -0,0 +1,89 @@ +package inbound + +import ( + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/protocol/socks" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" + "github.com/sagernet/sing/common/auth" +) + +type SocksManager struct { + inbounds map[string]*SocksUserManager + + mtx sync.Mutex +} + +func NewSocksManager() *SocksManager { + return &SocksManager{ + inbounds: make(map[string]*SocksUserManager), + } +} + +func (m *SocksManager) AddUserManager(inbound adapter.Inbound) error { + m.mtx.Lock() + defer m.mtx.Unlock() + m.inbounds[inbound.Tag()] = &SocksUserManager{ + inbound: inbound.(*socks.Inbound), + usersMap: make(map[string]auth.User), + } + return nil +} + +func (m *SocksManager) GetUserManager(tag string) (constant.UserManager, bool) { + m.mtx.Lock() + defer m.mtx.Unlock() + inbound, ok := m.inbounds[tag] + return inbound, ok +} + +func (m *SocksManager) GetUserManagerTags() []string { + m.mtx.Lock() + defer m.mtx.Unlock() + tags := make([]string, 0, len(m.inbounds)) + for tag := range m.inbounds { + tags = append(tags, tag) + } + return tags +} + +type SocksUserManager struct { + inbound *socks.Inbound + usersMap map[string]auth.User + + mtx sync.Mutex +} + +func (i *SocksUserManager) postUpdate() { + users := make([]auth.User, 0, len(i.usersMap)) + for _, user := range i.usersMap { + users = append(users, user) + } + i.inbound.UpdateUsers(users) +} + +func (i *SocksUserManager) UpdateUser(user CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + i.usersMap[user.Username] = auth.User{Username: user.Username, Password: user.Password} + i.postUpdate() +} + +func (i *SocksUserManager) UpdateUsers(users []CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.usersMap) + for _, user := range users { + i.usersMap[user.Username] = auth.User{Username: user.Username, Password: user.Password} + } + i.postUpdate() +} + +func (i *SocksUserManager) DeleteUser(username string) { + i.mtx.Lock() + defer i.mtx.Unlock() + delete(i.usersMap, username) + i.postUpdate() +} diff --git a/service/node/inbound/trojan.go b/service/node/inbound/trojan.go index 5faa4a0e..395ab6c1 100644 --- a/service/node/inbound/trojan.go +++ b/service/node/inbound/trojan.go @@ -11,8 +11,9 @@ import ( ) type TrojanManager struct { - access sync.Mutex inbounds map[string]*TrojanUserManager + + mtx sync.Mutex } func NewTrojanManager() *TrojanManager { @@ -22,8 +23,8 @@ func NewTrojanManager() *TrojanManager { } func (m *TrojanManager) AddUserManager(inbound adapter.Inbound) error { - m.access.Lock() - defer m.access.Unlock() + m.mtx.Lock() + defer m.mtx.Unlock() m.inbounds[inbound.Tag()] = &TrojanUserManager{ inbound: inbound.(*trojan.Inbound), usersMap: make(map[string]option.TrojanUser), @@ -32,15 +33,15 @@ func (m *TrojanManager) AddUserManager(inbound adapter.Inbound) error { } func (m *TrojanManager) GetUserManager(tag string) (constant.UserManager, bool) { - m.access.Lock() - defer m.access.Unlock() + m.mtx.Lock() + defer m.mtx.Unlock() inbound, ok := m.inbounds[tag] return inbound, ok } func (m *TrojanManager) GetUserManagerTags() []string { - m.access.Lock() - defer m.access.Unlock() + m.mtx.Lock() + defer m.mtx.Unlock() tags := make([]string, 0, len(m.inbounds)) for tag := range m.inbounds { tags = append(tags, tag) diff --git a/service/node/inbound/trusttunnel.go b/service/node/inbound/trusttunnel.go new file mode 100644 index 00000000..3830ede6 --- /dev/null +++ b/service/node/inbound/trusttunnel.go @@ -0,0 +1,89 @@ +package inbound + +import ( + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/trusttunnel" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" +) + +type TrustTunnelManager struct { + inbounds map[string]*TrustTunnelUserManager + + mtx sync.Mutex +} + +func NewTrustTunnelManager() *TrustTunnelManager { + return &TrustTunnelManager{ + inbounds: make(map[string]*TrustTunnelUserManager), + } +} + +func (m *TrustTunnelManager) AddUserManager(inbound adapter.Inbound) error { + m.mtx.Lock() + defer m.mtx.Unlock() + m.inbounds[inbound.Tag()] = &TrustTunnelUserManager{ + inbound: inbound.(*trusttunnel.Inbound), + usersMap: make(map[string]option.TrustTunnelUser), + } + return nil +} + +func (m *TrustTunnelManager) GetUserManager(tag string) (constant.UserManager, bool) { + m.mtx.Lock() + defer m.mtx.Unlock() + inbound, ok := m.inbounds[tag] + return inbound, ok +} + +func (m *TrustTunnelManager) GetUserManagerTags() []string { + m.mtx.Lock() + defer m.mtx.Unlock() + tags := make([]string, 0, len(m.inbounds)) + for tag := range m.inbounds { + tags = append(tags, tag) + } + return tags +} + +type TrustTunnelUserManager struct { + inbound *trusttunnel.Inbound + usersMap map[string]option.TrustTunnelUser + + mtx sync.Mutex +} + +func (i *TrustTunnelUserManager) postUpdate() { + users := make([]option.TrustTunnelUser, 0, len(i.usersMap)) + for _, user := range i.usersMap { + users = append(users, user) + } + i.inbound.UpdateUsers(users) +} + +func (i *TrustTunnelUserManager) UpdateUser(user CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + i.usersMap[user.Username] = option.TrustTunnelUser{Name: user.Username, Password: user.Password} + i.postUpdate() +} + +func (i *TrustTunnelUserManager) UpdateUsers(users []CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.usersMap) + for _, user := range users { + i.usersMap[user.Username] = option.TrustTunnelUser{Name: user.Username, Password: user.Password} + } + i.postUpdate() +} + +func (i *TrustTunnelUserManager) DeleteUser(username string) { + i.mtx.Lock() + defer i.mtx.Unlock() + delete(i.usersMap, username) + i.postUpdate() +} diff --git a/service/node/inbound/tuic.go b/service/node/inbound/tuic.go index 66f9845b..80b4e275 100644 --- a/service/node/inbound/tuic.go +++ b/service/node/inbound/tuic.go @@ -11,8 +11,9 @@ import ( ) type TUICManager struct { - access sync.Mutex inbounds map[string]*TUICUserManager + + mtx sync.Mutex } func NewTUICManager() *TUICManager { @@ -22,8 +23,8 @@ func NewTUICManager() *TUICManager { } func (m *TUICManager) AddUserManager(inbound adapter.Inbound) error { - m.access.Lock() - defer m.access.Unlock() + m.mtx.Lock() + defer m.mtx.Unlock() m.inbounds[inbound.Tag()] = &TUICUserManager{ inbound: inbound.(*tuic.Inbound), usersMap: make(map[string]option.TUICUser), @@ -32,15 +33,15 @@ func (m *TUICManager) AddUserManager(inbound adapter.Inbound) error { } func (m *TUICManager) GetUserManager(tag string) (constant.UserManager, bool) { - m.access.Lock() - defer m.access.Unlock() + m.mtx.Lock() + defer m.mtx.Unlock() inbound, ok := m.inbounds[tag] return inbound, ok } func (m *TUICManager) GetUserManagerTags() []string { - m.access.Lock() - defer m.access.Unlock() + m.mtx.Lock() + defer m.mtx.Unlock() tags := make([]string, 0, len(m.inbounds)) for tag := range m.inbounds { tags = append(tags, tag) diff --git a/service/node/inbound/vless.go b/service/node/inbound/vless.go index d3ef4d91..c5608620 100644 --- a/service/node/inbound/vless.go +++ b/service/node/inbound/vless.go @@ -11,8 +11,9 @@ import ( ) type VLESSManager struct { - access sync.Mutex inbounds map[string]*VLESSUserManager + + mtx sync.Mutex } func NewVLESSManager() *VLESSManager { @@ -22,8 +23,8 @@ func NewVLESSManager() *VLESSManager { } func (m *VLESSManager) AddUserManager(inbound adapter.Inbound) error { - m.access.Lock() - defer m.access.Unlock() + m.mtx.Lock() + defer m.mtx.Unlock() m.inbounds[inbound.Tag()] = &VLESSUserManager{ inbound: inbound.(*vless.Inbound), usersMap: make(map[string]option.VLESSUser), @@ -32,15 +33,15 @@ func (m *VLESSManager) AddUserManager(inbound adapter.Inbound) error { } func (m *VLESSManager) GetUserManager(tag string) (constant.UserManager, bool) { - m.access.Lock() - defer m.access.Unlock() + m.mtx.Lock() + defer m.mtx.Unlock() inbound, ok := m.inbounds[tag] return inbound, ok } func (m *VLESSManager) GetUserManagerTags() []string { - m.access.Lock() - defer m.access.Unlock() + m.mtx.Lock() + defer m.mtx.Unlock() tags := make([]string, 0, len(m.inbounds)) for tag := range m.inbounds { tags = append(tags, tag) diff --git a/service/node/inbound/vmess.go b/service/node/inbound/vmess.go index 1de1cad1..24c437e9 100644 --- a/service/node/inbound/vmess.go +++ b/service/node/inbound/vmess.go @@ -12,7 +12,8 @@ import ( type VMessManager struct { inbounds map[string]*VMessUserManager - mtx sync.Mutex + + mtx sync.Mutex } func NewVMessManager() *VMessManager { diff --git a/service/node/limiter/bandwidth.go b/service/node/limiter/bandwidth.go index 43d68438..b0fd3691 100644 --- a/service/node/limiter/bandwidth.go +++ b/service/node/limiter/bandwidth.go @@ -2,6 +2,7 @@ package limiter import ( "context" + "slices" "sync" "github.com/sagernet/sing-box/adapter" @@ -50,6 +51,7 @@ func (m *BandwidthLimiterManager) AddBandwidthLimiterStrategyManager(outbound ad manager: m, strategy: strategy, strategiesMap: make(map[string]bandwidth.BandwidthStrategy), + limitersMap: make(map[string]CM.BandwidthLimiter), } return nil } @@ -75,6 +77,7 @@ type BandwidthLimiterStrategyManager struct { manager *BandwidthLimiterManager strategy ManagedBandwidthStrategy strategiesMap map[string]bandwidth.BandwidthStrategy + limitersMap map[string]CM.BandwidthLimiter mtx sync.Mutex } @@ -86,12 +89,25 @@ func (i *BandwidthLimiterStrategyManager) postUpdate() { func (i *BandwidthLimiterStrategyManager) UpdateBandwidthLimiter(limiter CM.BandwidthLimiter) { i.mtx.Lock() defer i.mtx.Unlock() + if existing, ok := i.strategiesMap[limiter.Username]; ok { + oldLimiter := i.limitersMap[limiter.Username] + if isSameStrategy(oldLimiter, limiter) { + if oldLimiter.RawSpeed != limiter.RawSpeed { + if s, ok := existing.(bandwidth.SpeedUpdater); ok { + s.SetSpeed(limiter.RawSpeed) + } + } + i.limitersMap[limiter.Username] = limiter + return + } + } strategy, err := bandwidth.CreateStrategy(limiter.Strategy, limiter.Mode, limiter.ConnectionType, limiter.RawSpeed, limiter.FlowKeys) if err != nil { i.manager.logger.ErrorContext(i.manager.ctx, err) return } i.strategiesMap[limiter.Username] = strategy + i.limitersMap[limiter.Username] = limiter i.postUpdate() } @@ -118,3 +134,10 @@ func (i *BandwidthLimiterStrategyManager) DeleteBandwidthLimiter(username string delete(i.strategiesMap, username) i.postUpdate() } + +func isSameStrategy(a, b CM.BandwidthLimiter) bool { + return a.Strategy == b.Strategy && + a.Mode == b.Mode && + a.ConnectionType == b.ConnectionType && + slices.Equal(a.FlowKeys, b.FlowKeys) +} diff --git a/service/node/limiter/connection.go b/service/node/limiter/connection.go index ce51319e..02e8c3f3 100644 --- a/service/node/limiter/connection.go +++ b/service/node/limiter/connection.go @@ -6,6 +6,7 @@ import ( "time" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/onclose" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/protocol/limiter/connection" CM "github.com/sagernet/sing-box/service/manager/constant" @@ -45,7 +46,7 @@ func (m *ConnectionLimiterManager) AddConnectionLimiterStrategyManager(outbound } strategy, ok := limiter.GetStrategy().(ManagedConnectionStrategy) if !ok { - return E.New("strategy ", strategy, " is not manager") + return E.New("strategy for outbound ", outbound.Tag(), " is not manager") } m.managers[outbound.Tag()] = &ConnectionLimiterStrategyManager{ manager: m, @@ -152,7 +153,7 @@ type ManagerLock struct { func (i *ConnectionLimiterStrategyManager) newManagerLock(limiterId int) connection.LockIDGetter { conns := make(map[string]*ManagerLock) mtx := sync.Mutex{} - return func(id string) (connection.CloseHandlerFunc, context.Context, error) { + return func(id string) (onclose.CloseHandlerFunc, context.Context, error) { mtx.Lock() defer mtx.Unlock() conn, ok := conns[id] @@ -171,6 +172,7 @@ func (i *ConnectionLimiterStrategyManager) newManagerLock(limiterId int) connect case <-time.After(time.Second * 5): err := nodeManager.RefreshLock(limiterId, id, handleId) if err != nil { + i.manager.logger.ErrorContext(ctx, "failed to refresh lock: ", err) cancel() return } @@ -185,18 +187,17 @@ func (i *ConnectionLimiterStrategyManager) newManagerLock(limiterId int) connect conns[id] = conn } conn.handles++ - var once sync.Once return func() { - once.Do(func() { - mtx.Lock() - defer mtx.Unlock() - conn.handles-- - if conn.handles == 0 { - conn.cancel() - i.manager.nodeManager.ReleaseLock(limiterId, id, conn.handleId) - delete(conns, id) + mtx.Lock() + defer mtx.Unlock() + conn.handles-- + if conn.handles == 0 { + conn.cancel() + if err := i.manager.nodeManager.ReleaseLock(limiterId, id, conn.handleId); err != nil { + i.manager.logger.ErrorContext(i.manager.ctx, "failed to release lock: ", err) } - }) + delete(conns, id) + } }, conn.ctx, nil } } diff --git a/service/node/limiter/traffic.go b/service/node/limiter/traffic.go index f9c2fe53..74901ec7 100644 --- a/service/node/limiter/traffic.go +++ b/service/node/limiter/traffic.go @@ -212,5 +212,5 @@ func (l *TrafficLimiter) UpdateRemainingTraffic() error { } else { l.new += new } - return nil + return err } diff --git a/service/node/service.go b/service/node/service.go index bd8c31a4..be7f99c6 100644 --- a/service/node/service.go +++ b/service/node/service.go @@ -24,6 +24,7 @@ func RegisterService(registry *boxService.Registry) { type Service struct { boxService.Adapter ctx context.Context + cancel context.CancelFunc logger log.ContextLogger inboundManagers map[string]constant.InboundManager bandwidthManager constant.BandwidthLimiterManager @@ -32,13 +33,17 @@ type Service struct { rateManager constant.RateLimiterManager options option.NodeServiceOptions + nodeManager CM.NodeManager + mtx sync.Mutex } func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.NodeServiceOptions) (adapter.Service, error) { + ctx, cancel := context.WithCancel(ctx) return &Service{ - Adapter: boxService.NewAdapter(C.TypeManager, tag), + Adapter: boxService.NewAdapter(C.TypeNode, tag), ctx: ctx, + cancel: cancel, logger: logger, options: options, }, nil @@ -57,16 +62,23 @@ func (s *Service) Start(stage adapter.StartStage) error { if !ok { return E.New("invalid ", s.options.Manager, " manager") } + s.nodeManager = nodeManager inboundManager := service.FromContext[adapter.InboundManager](s.ctx) outboundManager := service.FromContext[adapter.OutboundManager](s.ctx) s.inboundManagers = map[string]constant.InboundManager{ - "hysteria": inbound.NewHysteriaManager(), - "hysteria2": inbound.NewHysteria2Manager(), - "mtproxy": inbound.NewMTProxyManager(), - "trojan": inbound.NewTrojanManager(), - "tuic": inbound.NewTUICManager(), - "vless": inbound.NewVLESSManager(), - "vmess": inbound.NewVMessManager(), + "anytls": inbound.NewAnyTLSManager(), + "http": inbound.NewHTTPManager(), + "hysteria": inbound.NewHysteriaManager(), + "hysteria2": inbound.NewHysteria2Manager(), + "mixed": inbound.NewMixedManager(), + "mtproxy": inbound.NewMTProxyManager(), + "naive": inbound.NewNaiveManager(), + "socks": inbound.NewSocksManager(), + "trojan": inbound.NewTrojanManager(), + "trusttunnel": inbound.NewTrustTunnelManager(), + "tuic": inbound.NewTUICManager(), + "vless": inbound.NewVLESSManager(), + "vmess": inbound.NewVMessManager(), } s.connectionManager = limiter.NewConnectionLimiterManager(s.ctx, nodeManager, s.logger) s.bandwidthManager = limiter.NewBandwidthLimiterManager(s.ctx, nodeManager, s.logger) @@ -320,5 +332,6 @@ func (s *Service) IsOnline() bool { } func (s *Service) Close() error { + s.cancel() return nil } diff --git a/service/node_manager_api/manager/manager.pb.go b/service/node_manager_api/manager/manager.pb.go index 06b1c29d..d16213ee 100644 --- a/service/node_manager_api/manager/manager.pb.go +++ b/service/node_manager_api/manager/manager.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v6.31.1 +// protoc v7.34.1 // source: service/node_manager_api/manager/manager.proto package manager diff --git a/service/node_manager_api/manager/manager_grpc.pb.go b/service/node_manager_api/manager/manager_grpc.pb.go index 15fd6b56..65ddfd40 100644 --- a/service/node_manager_api/manager/manager_grpc.pb.go +++ b/service/node_manager_api/manager/manager_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 -// - protoc v6.31.1 +// - protoc-gen-go-grpc v1.6.2 +// - protoc v7.34.1 // source: service/node_manager_api/manager/manager.proto package manager diff --git a/service/node_manager_api/server/node.go b/service/node_manager_api/server/node.go index a75652ba..9d597398 100644 --- a/service/node_manager_api/server/node.go +++ b/service/node_manager_api/server/node.go @@ -229,7 +229,7 @@ func (s *RemoteNode) send(data *pb.NodeData) { } func (s *RemoteNode) close(err error) { - if err != nil { + if err == nil || s.err != nil { return } s.err = err diff --git a/service/node_manager_api/server/server.go b/service/node_manager_api/server/server.go index dcdebd96..8aaa0a1c 100644 --- a/service/node_manager_api/server/server.go +++ b/service/node_manager_api/server/server.go @@ -5,6 +5,7 @@ import ( "crypto/subtle" "errors" "sync" + "time" "github.com/sagernet/sing-box/adapter" boxService "github.com/sagernet/sing-box/adapter/service" @@ -23,6 +24,7 @@ import ( "golang.org/x/net/http2" "google.golang.org/grpc" "google.golang.org/grpc/codes" + "google.golang.org/grpc/keepalive" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) @@ -134,9 +136,21 @@ func (s *APIServer) Start(stage adapter.StartStage) error { } tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig) } + keepAliveTime := time.Duration(s.options.KeepAlive) + if keepAliveTime <= 0 { + keepAliveTime = 10 * time.Second + } + keepAliveTimeout := time.Duration(s.options.KeepAliveTimeout) + if keepAliveTimeout <= 0 { + keepAliveTimeout = 5 * time.Second + } s.grpcServer = grpc.NewServer( grpc.ChainUnaryInterceptor(s.unaryAuthInterceptor), grpc.StreamInterceptor(s.streamAuthInterceptor), + grpc.KeepaliveParams(keepalive.ServerParameters{ + Time: keepAliveTime, + Timeout: keepAliveTimeout, + }), ) pb.RegisterManagerServer(s.grpcServer, s) go func() { diff --git a/test/go.mod b/test/go.mod index 7d6d9edc..14a4d20a 100644 --- a/test/go.mod +++ b/test/go.mod @@ -1,63 +1,101 @@ module test -go 1.24.7 +go 1.26.1 require github.com/sagernet/sing-box v0.0.0 replace github.com/sagernet/sing-box => ../ +replace github.com/sagernet/wireguard-go => github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.4.3 + +replace github.com/sagernet/tailscale => github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.7-extended-1.0.2 + +replace github.com/sagernet/sing-mux => github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0 + +replace github.com/ameshkov/dnscrypt/v2 => github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0 + +replace github.com/sagernet/sing-vmess => github.com/starifly/sing-vmess v0.2.7-mod.9 + +replace github.com/sagernet/sing => github.com/shtorm-7/sing v0.8.10-extended-1.0.0 + +replace github.com/dolonet/mtg-multi => github.com/shtorm-7/mtg-multi v1.8.0-extended-1.0.1 + +replace github.com/Diniboy1123/connect-ip-go => github.com/shtorm-7/connect-ip-go v1.0.0-extended-1.0.0 + +replace github.com/shtorm-7/go-cache/v2 => github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.1.0 + require ( - github.com/docker/docker v27.3.1+incompatible - github.com/docker/go-connections v0.5.0 + github.com/docker/docker v28.5.2+incompatible + github.com/docker/go-connections v0.6.0 github.com/gofrs/uuid/v5 v5.4.0 - github.com/sagernet/quic-go v0.59.0-sing-box-mod.2 - github.com/sagernet/sing v0.8.0-beta.16 - github.com/sagernet/sing-quic v0.6.0-beta.11 + github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 + github.com/sagernet/sing v0.8.10 + github.com/sagernet/sing-quic v0.6.1 github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/spyzhov/ajson v0.9.4 github.com/stretchr/testify v1.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/net v0.48.0 + golang.org/x/net v0.52.0 ) require ( filippo.io/edwards25519 v1.1.0 // indirect - github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/AdguardTeam/golibs v0.32.7 // indirect + github.com/AliRizaAynaci/gorl/v2 v2.2.0 // indirect + github.com/Diniboy1123/connect-ip-go v0.0.0-20260409225322-8d7bb0a858a2 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/OneOfOne/xxhash v1.2.8 // indirect github.com/ajg/form v1.5.1 // indirect github.com/akutz/memconn v0.1.0 // indirect github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect - github.com/andybalholm/brotli v1.1.0 // indirect - github.com/anthropics/anthropic-sdk-go v1.19.0 // indirect + github.com/ameshkov/dnscrypt/v2 v2.4.0 // indirect + github.com/ameshkov/dnsstamps v1.0.3 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/anthropics/anthropic-sdk-go v1.26.0 // indirect github.com/anytls/sing-anytls v0.0.11 // indirect - github.com/caddyserver/certmagic v0.25.0 // indirect - github.com/caddyserver/zerossl v0.1.3 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/caddyserver/certmagic v0.25.2 // indirect + github.com/caddyserver/zerossl v0.1.5 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coder/websocket v1.8.14 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect github.com/cretz/bine v0.2.0 // indirect github.com/database64128/netx-go v0.1.1 // indirect - github.com/database64128/tfo-go/v2 v2.3.1 // indirect + github.com/database64128/tfo-go/v2 v2.3.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect - github.com/distribution/reference v0.5.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/ebitengine/purego v0.9.1 // indirect + github.com/dolonet/mtg-multi v1.8.0 // indirect + github.com/dunglas/httpsfv v1.1.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/enfein/mieru/v3 v3.17.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/florianl/go-nfqueue/v2 v2.0.2 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gaissmai/bart v0.18.0 // indirect - github.com/go-chi/chi/v5 v5.2.3 // indirect + github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/go-chi/render v1.0.3 // indirect github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect - github.com/godbus/dbus/v5 v5.2.1 // indirect - github.com/gogo/protobuf v1.3.2 // indirect + github.com/gobwas/ws v1.4.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/golang-migrate/migrate/v4 v4.19.1 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/btree v1.1.3 // indirect github.com/google/go-cmp v0.7.0 // indirect @@ -65,73 +103,96 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect - github.com/insomniacslk/dhcp v0.0.0-20251020182700-175e84fbb167 // indirect + github.com/huandu/go-clone v1.7.3 // indirect + github.com/huandu/go-sqlbuilder v1.39.1 // indirect + github.com/huandu/xstrings v1.4.0 // indirect + github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.8.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jsimonetti/rtnetlink v1.4.0 // indirect github.com/keybase/go-keychain v0.0.1 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/libdns/acmedns v0.5.0 // indirect - github.com/libdns/alidns v1.0.6-beta.3 // indirect + github.com/libdns/alidns v1.0.6 // indirect github.com/libdns/cloudflare v0.2.2 // indirect github.com/libdns/libdns v1.1.1 // indirect github.com/logrusorgru/aurora v2.0.3+incompatible // indirect - github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mdlayher/netlink v1.9.0 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/metacubex/utls v1.8.4 // indirect - github.com/mholt/acmez/v3 v3.1.4 // indirect - github.com/miekg/dns v1.1.69 // indirect + github.com/mholt/acmez/v3 v3.1.6 // indirect + github.com/miekg/dns v1.1.72 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/term v0.5.0 // indirect - github.com/morikuni/aec v1.0.0 // indirect - github.com/openai/openai-go/v3 v3.15.0 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/openai/openai-go/v3 v3.26.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.0 // indirect - github.com/pierrec/lz4/v4 v4.1.21 // indirect - github.com/pires/go-proxyproto v0.8.1 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/panjf2000/ants/v2 v2.12.0 // indirect + github.com/pierrec/lz4/v4 v4.1.25 // indirect + github.com/pires/go-proxyproto v0.11.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus-community/pro-bing v0.4.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/redis/go-redis/v9 v9.8.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/safchain/ethtool v0.3.0 // indirect github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a // indirect github.com/sagernet/cors v1.2.1 // indirect - github.com/sagernet/cronet-go v0.0.0-20260117110918-dc1cda1fe287 // indirect - github.com/sagernet/cronet-go/all v0.0.0-20260117110918-dc1cda1fe287 // indirect - github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260117110516-f21660bef13f // indirect - github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260117110516-f21660bef13f // indirect - github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260117110516-f21660bef13f // indirect - github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260117110516-f21660bef13f // indirect - github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260117110516-f21660bef13f // indirect - github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260117110516-f21660bef13f // indirect - github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260117110516-f21660bef13f // indirect - github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260117110516-f21660bef13f // indirect - github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260117110516-f21660bef13f // indirect - github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260117110516-f21660bef13f // indirect - github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260117110516-f21660bef13f // indirect - github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260117110516-f21660bef13f // indirect - github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260117110516-f21660bef13f // indirect - github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260117110516-f21660bef13f // indirect - github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260117110516-f21660bef13f // indirect - github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260117110516-f21660bef13f // indirect - github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260117110516-f21660bef13f // indirect - github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260117110516-f21660bef13f // indirect - github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260117110516-f21660bef13f // indirect - github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260117110516-f21660bef13f // indirect - github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260117110516-f21660bef13f // indirect - github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260117110516-f21660bef13f // indirect - github.com/sagernet/fswatch v0.1.1 // indirect + github.com/sagernet/cronet-go v0.0.0-20260513071958-2faf34666c2c // indirect + github.com/sagernet/cronet-go/all v0.0.0-20260513071958-2faf34666c2c // indirect + github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260513071149-ade33496efb8 // indirect + github.com/sagernet/fswatch v0.1.2 // indirect github.com/sagernet/gvisor v0.0.0-20250822052253-5558536cf237 // indirect github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect - github.com/sagernet/nftables v0.3.0-beta.4 // indirect + github.com/sagernet/nftables v0.3.0-mod.2 // indirect github.com/sagernet/sing-mux v0.3.4 // indirect github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 // indirect - github.com/sagernet/sing-tun v0.8.0-beta.17 // indirect + github.com/sagernet/sing-tun v0.8.9 // indirect github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 // indirect github.com/sagernet/smux v1.5.50-sing-box-mod.1 // indirect - github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6 // indirect - github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20250917110311-16510ac47288 // indirect + github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7 // indirect + github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c // indirect github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 // indirect + github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.0.2 // indirect + github.com/shtorm-7/workerpool v0.5.0 // indirect github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect @@ -143,37 +204,43 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + github.com/tylertreat/BoomFilters v0.0.0-20251117164519-53813c36cc1b // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zeebo/blake3 v0.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect - go.opentelemetry.io/otel v1.38.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 // indirect - go.opentelemetry.io/otel/metric v1.38.0 // indirect - go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.uber.org/zap/exp v0.3.0 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect - golang.org/x/crypto v0.46.0 // indirect - golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect - golang.org/x/mod v0.31.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/term v0.38.0 // indirect - golang.org/x/text v0.32.0 // indirect - golang.org/x/time v0.11.0 // indirect - golang.org/x/tools v0.40.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.12.0 // indirect + golang.org/x/tools v0.43.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect - google.golang.org/grpc v1.77.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.1 // indirect - lukechampine.com/blake3 v1.3.0 // indirect + lukechampine.com/blake3 v1.4.1 // indirect + modernc.org/libc v1.72.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.50.0 // indirect ) diff --git a/test/go.sum b/test/go.sum index 34f8d997..9866e46d 100644 --- a/test/go.sum +++ b/test/go.sum @@ -1,72 +1,115 @@ +code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE= +code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= -github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= -github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/AdguardTeam/golibs v0.32.7 h1:3dmGlAVgmvquCCwHsvEl58KKcRAK3z1UnjMnwSIeDH4= +github.com/AdguardTeam/golibs v0.32.7/go.mod h1:bE8KV1zqTzgZjmjFyBJ9f9O5DEKO717r7e57j1HclJA= +github.com/AliRizaAynaci/gorl/v2 v2.2.0 h1:E8oAwkordOwm9ItNNVJ5VKvGroDcHvWNvG11HaCVLZI= +github.com/AliRizaAynaci/gorl/v2 v2.2.0/go.mod h1:13wcj/W736v44b6uygUuwypMY9N3RXJuhAYXukIIdCo= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= +github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= -github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= -github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= -github.com/anthropics/anthropic-sdk-go v1.19.0 h1:mO6E+ffSzLRvR/YUH9KJC0uGw0uV8GjISIuzem//3KE= -github.com/anthropics/anthropic-sdk-go v1.19.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/ameshkov/dnsstamps v1.0.3 h1:Srzik+J9mivH1alRACTbys2xOxs0lRH9qnTA7Y1OYVo= +github.com/ameshkov/dnsstamps v1.0.3/go.mod h1:Ii3eUu73dx4Vw5O4wjzmT5+lkCwovjzaEZZ4gKyIH5A= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= +github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc= github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8= -github.com/caddyserver/certmagic v0.25.0 h1:VMleO/XA48gEWes5l+Fh6tRWo9bHkhwAEhx63i+F5ic= -github.com/caddyserver/certmagic v0.25.0/go.mod h1:m9yB7Mud24OQbPHOiipAoyKPn9pKHhpSJxXR1jydBxA= -github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA= -github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= +github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6 h1:4NNbNM2Iq/k57qEu7WfL67UrbPq1uFWxW4qODCohi+0= +github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6/go.mod h1:J29hk+f9lJrblVIfiJOtTFk+OblBawmib4uz/VdKzlg= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/caddyserver/certmagic v0.25.2 h1:D7xcS7ggX/WEY54x0czj7ioTkmDWKIgxtIi2OcQclUc= +github.com/caddyserver/certmagic v0.25.2/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg= +github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= +github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= github.com/cretz/bine v0.2.0 h1:8GiDRGlTgz+o8H9DSnsl+5MeBK4HsExxgl6WgzOCuZo= github.com/cretz/bine v0.2.0/go.mod h1:WU4o9QR9wWp8AVKtTM1XD5vUHkEqnf2vVSo6dBqbetI= +github.com/d4l3k/messagediff v1.2.1 h1:ZcAIMYsUg0EAp9X+tt8/enBE/Q8Yd5kzPynLyKptt9U= +github.com/d4l3k/messagediff v1.2.1/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= github.com/database64128/netx-go v0.1.1 h1:dT5LG7Gs7zFZBthFBbzWE6K8wAHjSNAaK7wCYZT7NzM= github.com/database64128/netx-go v0.1.1/go.mod h1:LNlYVipaYkQArRFDNNJ02VkNV+My9A5XR/IGS7sIBQc= -github.com/database64128/tfo-go/v2 v2.3.1 h1:EGE+ELd5/AQ0X6YBlQ9RgKs8+kciNhgN3d8lRvfEJQw= -github.com/database64128/tfo-go/v2 v2.3.1/go.mod h1:k9wcpg/8i5zenspBkc9jUEYehpZZccBnCElzOJB++bU= +github.com/database64128/tfo-go/v2 v2.3.2 h1:UhZMKiMq3swZGUiETkLBDzQnZBPSAeBMClpJGlnJ5Fw= +github.com/database64128/tfo-go/v2 v2.3.2/go.mod h1:GC3uB5oa4beGpCUbRb2ZOWP73bJJFmMyAVgQSO7r724= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= -github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= -github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= -github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= -github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54= +github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/enfein/mieru/v3 v3.17.1 h1:pIKbspsKRYNyUrORVI33t1/yz2syaaUkIanskAbGBHY= +github.com/enfein/mieru/v3 v3.17.1/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/florianl/go-nfqueue/v2 v2.0.2 h1:FL5lQTeetgpCvac1TRwSfgaXUn0YSO7WzGvWNIp3JPE= github.com/florianl/go-nfqueue/v2 v2.0.2/go.mod h1:VA09+iPOT43OMoCKNfXHyzujQUty2xmzyCRkBOlmabc= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo= github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY= github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= -github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= -github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I= github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -76,16 +119,26 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= -github.com/godbus/dbus/v5 v5.2.1 h1:I4wwMdWSkmI57ewd+elNGwLRf2/dtSaFz1DujfWYvOk= -github.com/godbus/dbus/v5 v5.2.1/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -96,70 +149,112 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= +github.com/hashicorp/golang-lru v0.6.0 h1:uL2shRDx7RTrOrTCUZEGP/wJUFiUI8QT6E7z5o8jga4= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= -github.com/insomniacslk/dhcp v0.0.0-20251020182700-175e84fbb167 h1:MEufgJohwIjFi2n3eJv4c/8UdRLQVUwPwSWQPoER+eU= -github.com/insomniacslk/dhcp v0.0.0-20251020182700-175e84fbb167/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo= +github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= +github.com/huandu/go-assert v1.1.6 h1:oaAfYxq9KNDi9qswn/6aE0EydfxSa+tWZC1KabNitYs= +github.com/huandu/go-assert v1.1.6/go.mod h1:JuIfbmYG9ykwvuxoJ3V8TB5QP+3+ajIA54Y44TmkMxs= +github.com/huandu/go-clone v1.7.3 h1:rtQODA+ABThEn6J5LBTppJfKmZy/FwfpMUWa8d01TTQ= +github.com/huandu/go-clone v1.7.3/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE= +github.com/huandu/go-sqlbuilder v1.39.1 h1:uUaj41yLNTQBe7ojNF6Im1RPbHCN4zCjMRySTEC2ooI= +github.com/huandu/go-sqlbuilder v1.39.1/go.mod h1:zdONH67liL+/TvoUMwnZP/sUYGSSvHh9psLe/HpXn8E= +github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= +github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 h1:u9i04mGE3iliBh0EFuWaKsmcwrLacqGmq1G3XoaM7gY= +github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU= +github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk= +github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYqCLCnj/U= +github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/libdns/acmedns v0.5.0 h1:5pRtmUj4Lb/QkNJSl1xgOGBUJTWW7RjpNaIhjpDXjPE= github.com/libdns/acmedns v0.5.0/go.mod h1:X7UAFP1Ep9NpTwWpVlrZzJLR7epynAy0wrIxSPFgKjQ= -github.com/libdns/alidns v1.0.6-beta.3 h1:KAmb7FQ1tRzKsaAUGa7ZpGKAMRANwg7+1c7tUbSELq8= -github.com/libdns/alidns v1.0.6-beta.3/go.mod h1:RECwyQ88e9VqQVtSrvX76o1ux3gQUKGzMgxICi+u7Ec= +github.com/libdns/alidns v1.0.6 h1:/Ii428ty6WHFJmE24rZxq2taq++gh7rf9jhgLfp8PmM= +github.com/libdns/alidns v1.0.6/go.mod h1:RECwyQ88e9VqQVtSrvX76o1ux3gQUKGzMgxICi+u7Ec= github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI= github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= -github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= -github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mdlayher/netlink v1.9.0 h1:G8+GLq2x3v4D4MVIqDdNUhTUC7TKiCy/6MDkmItfKco= +github.com/mdlayher/netlink v1.9.0/go.mod h1:YBnl5BXsCoRuwBjKKlZ+aYmEoq0r12FDA/3JC+94KDg= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg= github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= -github.com/mholt/acmez/v3 v3.1.4 h1:DyzZe/RnAzT3rpZj/2Ii5xZpiEvvYk3cQEN/RmqxwFQ= -github.com/mholt/acmez/v3 v3.1.4/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ= -github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc= -github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g= +github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= +github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= -github.com/openai/openai-go/v3 v3.15.0 h1:hk99rM7YPz+M99/5B/zOQcVwFRLLMdprVGx1vaZ8XMo= -github.com/openai/openai-go/v3 v3.15.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/openai/openai-go/v3 v3.26.0 h1:bRt6H/ozMNt/dDkN4gobnLqaEGrRGBzmbVs0xxJEnQE= +github.com/openai/openai-go/v3 v3.26.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= -github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= -github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= -github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/panjf2000/ants/v2 v2.12.0 h1:u9JhESo83i/GkZnhfTNuFMMWcNt7mnV1bGJ6FT4wXH8= +github.com/panjf2000/ants/v2 v2.12.0/go.mod h1:tSQuaNQ6r6NRhPt+IZVUevvDyFMTs+eS4ztZc52uJTY= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= +github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4= +github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -169,101 +264,137 @@ github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyf github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI= +github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= +github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a h1:+NkI2670SQpQWvkkD2QgdTuzQG263YZ+2emfpeyGqW0= github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a/go.mod h1:63s7jpZqcDAIpj8oI/1v4Izok+npJOHACFCU6+huCkM= github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ= github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI= -github.com/sagernet/cronet-go v0.0.0-20260117110918-dc1cda1fe287 h1:0BYNmr0ptjsII948U0oBFmrbo4qEaCFcrE2JPRg3Zlk= -github.com/sagernet/cronet-go v0.0.0-20260117110918-dc1cda1fe287/go.mod h1:hwFHBEjjthyEquDULbr4c4ucMedp8Drb6Jvm2kt/0Bw= -github.com/sagernet/cronet-go/all v0.0.0-20260117110918-dc1cda1fe287 h1:ghxhYSBQpzkakqWqJDvXr/Zmxe0WjTjKuALEGbjGiGY= -github.com/sagernet/cronet-go/all v0.0.0-20260117110918-dc1cda1fe287/go.mod h1:M+4ZjPhLJXIvoxcQsbDofmc19Wrig59hZ+hLvj6S3To= -github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260117110516-f21660bef13f h1:8jZbZ4KBTdcXDFLwUBNQt5Xci6ZuAKh255S8TwuBCaM= -github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260117110516-f21660bef13f/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= -github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260117110516-f21660bef13f h1:tG0hCx+0u5zca7qQ7AMkcv4DCrBG/DKW1ggs/P+BRRI= -github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260117110516-f21660bef13f/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= -github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260117110516-f21660bef13f h1:ZXp5hKJIA7iJ52ZShJCKMQEPLpp/7dDIVZmPGV9Il40= -github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260117110516-f21660bef13f/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= -github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260117110516-f21660bef13f h1:gL7H8HS8s38adz4/HZtRHh79qMwsbLTRRPz4GQ9LcWI= -github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260117110516-f21660bef13f/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= -github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260117110516-f21660bef13f h1:Dchgc0pAY5Jwb5lzUlE+1nhHIzqLx+YOurXLHgvWd/0= -github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260117110516-f21660bef13f/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= -github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260117110516-f21660bef13f h1:+MOLSQoduuKDxF410i1LcSPaQGaiP0eZb0INvMlmjM4= -github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260117110516-f21660bef13f/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= -github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260117110516-f21660bef13f h1:lIZna05Vn6n8k21p8OpSUnhwGm+E57PrMjiI4ZUfMSg= -github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260117110516-f21660bef13f/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= -github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260117110516-f21660bef13f h1:B2aFQ5CRHI20t8YsEizvtguS5W2QfK7D5XV/NzTIxPE= -github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260117110516-f21660bef13f/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= -github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260117110516-f21660bef13f h1:qpSwJ1rFGYCfJDenNCZoWYjoG7N+xEa6ke+E7/JO1i4= -github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260117110516-f21660bef13f/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= -github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260117110516-f21660bef13f h1:cx7Ipg0tSvTDjS4maMEYz4vuzz93BMPAysmZ1YLrz80= -github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260117110516-f21660bef13f/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= -github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260117110516-f21660bef13f h1:4jOHuUiBxD8pJEpBBVQfJqyLmxjpd3t4MLRzU7YLFyg= -github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260117110516-f21660bef13f/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= -github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260117110516-f21660bef13f h1:OpXBa2WlRU+Mam9oRe9Nn4/zf7gQ+qiBTNK8A5RwbfQ= -github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260117110516-f21660bef13f/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= -github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260117110516-f21660bef13f h1:nJpGFi+6hI85tl4zoyNFEnFEQ5+xEV5gyvsUoMvd8g0= -github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260117110516-f21660bef13f/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= -github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260117110516-f21660bef13f h1:SEy2rpmgOJgrqcEryJI/RSnqUWIsEsp0cfYoA8y21jc= -github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260117110516-f21660bef13f/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= -github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260117110516-f21660bef13f h1:EW2TuFMLm0iBGqRZtuGwIZdeYmDtDsDmRcRRJQOMxUo= -github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260117110516-f21660bef13f/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= -github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260117110516-f21660bef13f h1:3U5woxrNCkzfv1+UX+mVoWh1228AE1qAiMG02F9oFbY= -github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260117110516-f21660bef13f/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= -github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260117110516-f21660bef13f h1:YwFTfuWG3mmctroeDYtFZ6LHjGsedVO+5wInYbbUuUY= -github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260117110516-f21660bef13f/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= -github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260117110516-f21660bef13f h1:r4V0ddPCRLgGu0VdgR3aUsO9NjpmyjAf+h+3oTD9D6E= -github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260117110516-f21660bef13f/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= -github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260117110516-f21660bef13f h1:B8yf4gFvEYUnwWmtVK9sdwUsflYZ387MhYmlOP2ohFQ= -github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260117110516-f21660bef13f/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= -github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260117110516-f21660bef13f h1:9YyaMg4rO1/jIgrxmNb0LKH+X7frSYWfX2pFgW5JUVM= -github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260117110516-f21660bef13f/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= -github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260117110516-f21660bef13f h1:B0fnGu0sh9yT/9JDN5u/GqThGoOzNN/daOAuGWFLXEk= -github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260117110516-f21660bef13f/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= -github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260117110516-f21660bef13f h1:lxPcIXKSSI5JDhc7rx/6yufISWM4vtBS2FY9PavWQTs= -github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260117110516-f21660bef13f/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= -github.com/sagernet/fswatch v0.1.1 h1:YqID+93B7VRfqIH3PArW/XpJv5H4OLEVWDfProGoRQs= -github.com/sagernet/fswatch v0.1.1/go.mod h1:nz85laH0mkQqJfaOrqPpkwtU1znMFNVTpT/5oRsVz/o= +github.com/sagernet/cronet-go v0.0.0-20260513071958-2faf34666c2c h1:JatMWK/reVa5Y+x3D3l49SVtHB/EQUEtQnAFTxPBNxY= +github.com/sagernet/cronet-go v0.0.0-20260513071958-2faf34666c2c/go.mod h1:T/mwtrpC4JlWfScw73CmSBvHzIvc7BatQ1MhRr+cYNw= +github.com/sagernet/cronet-go/all v0.0.0-20260513071958-2faf34666c2c h1:F/tL+VzLZ2F4SNZZze6SRSRL/jcX7LwIsuL1+hECiz0= +github.com/sagernet/cronet-go/all v0.0.0-20260513071958-2faf34666c2c/go.mod h1:GGE1tBbFgHq8kV99AKX1JXFY+9FvgNSK/W6Z5j24Ihc= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260513071149-ade33496efb8 h1:NCKxyAnEkwsEueAEbuuUUjs2FEZAIflr+WN3Mwbvsdg= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260513071149-ade33496efb8/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260513071149-ade33496efb8 h1:o3AGm7/L/zAdBvPu0u1dFgDR/tH086qyuXZkjLNJ7/E= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260513071149-ade33496efb8 h1:AeO8yHQj7aNj16fiJNU797alyuM3T+3VASnETHeV220= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260513071149-ade33496efb8/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260513071149-ade33496efb8 h1:ZgW2/Qq/5Q6eTlW80QXLokU56kfjvbLJSEGYTkcG3hU= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260513071149-ade33496efb8 h1:orYgvX5X9aUa+sRrAuuqA6PXiiBUI2D367ZJqan4lIU= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260513071149-ade33496efb8 h1:2w1s3wEk7qW2w4IGwlJflxwXBM97UChNiqAErKpvHr0= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260513071149-ade33496efb8 h1:22k6CB3d4gHT+SARUh2bgNyGU4QwYupcCdP8cGuwygY= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260513071149-ade33496efb8/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260513071149-ade33496efb8 h1:PkJ5EaqLrv6bNR+MHx1/joJXoRcoYcV7JA4NtXbFQsc= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260513071149-ade33496efb8 h1:V629H+OQ9yOR2d0Jkq5y42j5btpvoSWJbUaBH7FCGPI= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260513071149-ade33496efb8/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260513071149-ade33496efb8 h1:gfObF5uoqJslCdMRRm2Yo+gmPJQPVlrci5Myrki0Kzk= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260513071149-ade33496efb8/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260513071149-ade33496efb8 h1:JRPN0RBKvoOBEHezJh/54KD9ftWL7YadtcCgOf/vRnw= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260513071149-ade33496efb8/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260513071149-ade33496efb8 h1:mM8gNdFlXSpjZFs9kgaMgW94oTRF8YdEEQgdOp/OEUA= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260513071149-ade33496efb8 h1:ZtCH0fH07giTK6wqkenA9fdFYt7krjWiyOvC8z9nPwk= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260513071149-ade33496efb8/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260513071149-ade33496efb8 h1:Uviqmw+Q4No9kCxJWJ5CYcq6PNHB9f0jQhd15j39+no= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260513071149-ade33496efb8/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260513071149-ade33496efb8 h1:la4zRTE9zpZCmsixwzKT2LnHuo0e439EmGwOlB1An9Q= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260513071149-ade33496efb8 h1:KodFGMqn+X2dqET0O3xww3iemAGmpoC8U4JW8gwt0x4= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260513071149-ade33496efb8/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260513071149-ade33496efb8 h1:QTk1RXNLOIcorZYcF0rBrwLpCIZCKEA2Jr69eFrt8xg= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260513071149-ade33496efb8/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260513071149-ade33496efb8 h1:SXqSlM/GjZFvNdUV3IvHq5gqHfW4iWlQHMGzEsgXGXE= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260513071149-ade33496efb8 h1:aAgLWpfESvy7rfDVH7ioOZQ7u2kmRsbUqJVrwJtkFWs= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260513071149-ade33496efb8/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260513071149-ade33496efb8 h1:oTLUyhLckc8TZQ8SRCapgTYyRbz1pBpIvzjMCLMPFu8= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260513071149-ade33496efb8/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260513071149-ade33496efb8 h1:LHm/85Y3zN0kNgG+li5qHvP3dzvavEytCYzdLtrfrrg= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260513071149-ade33496efb8/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260513071149-ade33496efb8 h1:Pom5TSHV8Cln73uOgQlJ+JtmEu9xh+OuLHWq57dBaVg= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260513071149-ade33496efb8/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260513071149-ade33496efb8 h1:1pPcb15BonaFl4153tRo7zOJ7U2zD1vjH+5JipSfJ3g= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260513071149-ade33496efb8 h1:3Dy4exYQ/IVJGcnTtvW3LmjfjDaxFgJT1hn/ALBpd2M= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260513071149-ade33496efb8/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260513071149-ade33496efb8 h1:mo9YMCYTGCRUiWNKtPVQb+qEetufxnch372xUOh9q3M= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260513071149-ade33496efb8/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260513071149-ade33496efb8 h1:mhh3JEDDx68oKT4kfqKlWp5QTyzVR84OS/qgqHYIbq0= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260513071149-ade33496efb8 h1:04KOo38hZojV3bJ5Vqwbpj48ZQy6o7aliYXLN/TNX6g= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260513071149-ade33496efb8/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260513071149-ade33496efb8 h1:p535QakpDZEeBz/BfFZGZo0D+Pdn74TE8UTr6c6MSog= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260513071149-ade33496efb8 h1:dovTyKHh3toBIUOS70P4Yx+3Baw6Gppsfy1sJbXoAy0= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260513071149-ade33496efb8/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= +github.com/sagernet/fswatch v0.1.2 h1:/TT7k4mkce1qFPxamLO842WjqBgbTBiXP2mlUjp9PFk= +github.com/sagernet/fswatch v0.1.2/go.mod h1:5BpGmpUQVd3Mc5r313HRpvADHRg3/rKn5QbwFteB880= github.com/sagernet/gvisor v0.0.0-20250822052253-5558536cf237 h1:SUPFNB+vSP4RBPrSEgNII+HkfqC8hKMpYLodom4o4EU= github.com/sagernet/gvisor v0.0.0-20250822052253-5558536cf237/go.mod h1:QkkPEJLw59/tfxgapHta14UL5qMUah5NXhO0Kw2Kan4= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= -github.com/sagernet/nftables v0.3.0-beta.4 h1:kbULlAwAC3jvdGAC1P5Fa3GSxVwQJibNenDW2zaXr8I= -github.com/sagernet/nftables v0.3.0-beta.4/go.mod h1:OQXAjvjNGGFxaTgVCSTRIhYB5/llyVDeapVoENYBDS8= -github.com/sagernet/quic-go v0.59.0-sing-box-mod.2 h1:hJUL+HtxEOjxsa0CsucbBVqI/AMS4k52NwNU637zmdw= -github.com/sagernet/quic-go v0.59.0-sing-box-mod.2/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= -github.com/sagernet/sing v0.8.0-beta.16 h1:Fe+6E9VHYky9Mx4cf0ugbZPWDcXRflpAu7JQ5bWXvaA= -github.com/sagernet/sing v0.8.0-beta.16/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= -github.com/sagernet/sing-mux v0.3.4 h1:ZQplKl8MNXutjzbMVtWvWG31fohhgOfCuUZR4dVQ8+s= -github.com/sagernet/sing-mux v0.3.4/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= -github.com/sagernet/sing-quic v0.6.0-beta.11 h1:eUusxITKKRedhWC2ScUYFUvD96h/QfbKLaS3N6/7in4= -github.com/sagernet/sing-quic v0.6.0-beta.11/go.mod h1:K5bWvITOm4vE10fwLfrWpw27bCoVJ+tfQ79tOWg+Ko8= +github.com/sagernet/nftables v0.3.0-mod.2 h1:ck2KMU02OxL1eDFgGaWYglMDpoOZ7OHzxje+vW5Q0OQ= +github.com/sagernet/nftables v0.3.0-mod.2/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= +github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o= +github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= +github.com/sagernet/sing-quic v0.6.1 h1:lx0tcm99wIA1RkyvILNzRSsMy1k7TTQYIhx71E/WBlw= +github.com/sagernet/sing-quic v0.6.1/go.mod h1:K5bWvITOm4vE10fwLfrWpw27bCoVJ+tfQ79tOWg+Ko8= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI= github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo= github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ= github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 h1:tK+75l64tm9WvEFrYRE1t0YxoFdWQqw/h7Uhzj0vJ+w= github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= -github.com/sagernet/sing-tun v0.8.0-beta.17 h1:6DdbNXeTFYj8Tb4FCh8Mp2boA3rVY6VNqzTOObj7Xis= -github.com/sagernet/sing-tun v0.8.0-beta.17/go.mod h1:+HAK/y9GZljdT0KYKMYDR8MjjqnqDDQZYp5ZZQoRzS8= -github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= -github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY= +github.com/sagernet/sing-tun v0.8.9 h1:ixFKKUGdVcJl4wb0xbL36hobiw9l6DIH497EQf5ILpM= +github.com/sagernet/sing-tun v0.8.9/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ= github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478= github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= -github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6 h1:eYz/OpMqWCvO2++iw3dEuzrlfC2xv78GdlGvprIM6O8= -github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6/go.mod h1:m87GAn4UcesHQF3leaPFEINZETO5za1LGn1GJdNDgNc= -github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20250917110311-16510ac47288 h1:E2tZFeg9mGYGQ7E7BbxMv1cU35HxwgRm6tPKI2Pp7DA= -github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20250917110311-16510ac47288/go.mod h1:WUxgxUDZoCF2sxVmW+STSxatP02Qn3FcafTiI2BLtE0= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA= +github.com/shtorm-7/connect-ip-go v1.0.0-extended-1.0.0 h1:ws7BIsYLd31Wjifq88BYCHRVlgO+07iwil39s6ERba8= +github.com/shtorm-7/connect-ip-go v1.0.0-extended-1.0.0/go.mod h1:mRwx4w32qQxsWB2kThuHpbo7iNjJiq1jYWubgqEPjHA= +github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0 h1:e5s7RKBd2rIPR0StbvZ2vTVtJ5jDTsTk5wtIIapZTRg= +github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0/go.mod h1:WpEFV2uhebXb8Jhes/5/fSdpmhGV8TL22RDaeWwV6hI= +github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.1.0 h1:PLZ/YHqnApPx13wt6MX3ItqESp4ueBr1tGSi0bEGqYw= +github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.1.0/go.mod h1:Ek4yz5OK6stwhLKgLsRRYDI+FA+ZWvRJiWLjsi/vMM4= +github.com/shtorm-7/mtg-multi v1.8.0-extended-1.0.1 h1:UeJkrCJJmIjTBywErVMx7fCSoBf4gh6QgT9bp9o1ajM= +github.com/shtorm-7/mtg-multi v1.8.0-extended-1.0.1/go.mod h1:3rvdhwdPABkwKBdvgMt3VwMn9uSq8hpoHRezZ5jRJU0= +github.com/shtorm-7/sing v0.8.10-extended-1.0.0 h1:mAkyycCQOzCttPOR5fcHkJaZvXMQXeu3mbEfr8D+7A8= +github.com/shtorm-7/sing v0.8.10-extended-1.0.0/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= +github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0 h1:a5OoXr3e2ACbM6vDIaaGL44IdHQ6wPjcSoU13vfC0Sw= +github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= +github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.7-extended-1.0.2 h1:hSMjh97OszszOd8HrzpaYUQH9dWRRBluJCbwQyz8ZOk= +github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.7-extended-1.0.2/go.mod h1:TYIIqO5sZpWq873rLIeO2usszSMUpR3h6WdqVVs65ug= +github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.4.3 h1:jtOA73D4F5qRV70//ahOt20KBnWvQimAFjtIiOtt0ps= +github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.4.3/go.mod h1:Me2JlCDYHxnd0mnuX7L5LXAeDHCltI7vSKq3eTE6SVE= +github.com/shtorm-7/workerpool v0.5.0 h1:NPZuNgyH0EUm4aQsTL09xR1iV+7GCFw6jX9Z4aAVp2s= +github.com/shtorm-7/workerpool v0.5.0/go.mod h1:NI0pUZgmGu0BdKO9j3mct1DNZmgXbyTS9foorljdH6E= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spyzhov/ajson v0.9.4 h1:MVibcTCgO7DY4IlskdqIlCmDOsUOZ9P7oKj8ifdcf84= github.com/spyzhov/ajson v0.9.4/go.mod h1:a6oSw0MMb7Z5aD2tPoPO+jq11ETKgXUr2XktHdT8Wt8= +github.com/starifly/sing-vmess v0.2.7-mod.9 h1:xobAmejSbBQ0A3f/EtJ9cJd3m6gK7dDPccPdeGz7tXY= +github.com/starifly/sing-vmess v0.2.7-mod.9/go.mod h1:5aYoOtYksAyS0NXDm0qKeTYW1yoE1bJVcv+XLcVoyJs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= @@ -293,6 +424,12 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/txthinking/runnergroup v0.0.0-20250224021307-5864ffeb65ae h1:ArVM1jICfm7g4E4dBet+KHUFMLuxmj1Nxdp/tr3ByCU= +github.com/txthinking/runnergroup v0.0.0-20250224021307-5864ffeb65ae/go.mod h1:cldYm15/XHcGt7ndItnEWHwFZo7dinU+2QoyjfErhsI= +github.com/txthinking/socks5 v0.0.0-20251011041537-5c31f201a10e h1:xA7GVlbz6teIF4FdvuqwbX6C4tiqNk2PH7FRPIDerao= +github.com/txthinking/socks5 v0.0.0-20251011041537-5c31f201a10e/go.mod h1:ntmMHL/xPq1WLeKiw8p/eRATaae6PiVRNipHFJxI8PM= +github.com/tylertreat/BoomFilters v0.0.0-20251117164519-53813c36cc1b h1:p+bJ3v5uUdEVMCoeFUs+BNJPsqt+Y6BLbDaPfTcbcH8= +github.com/tylertreat/BoomFilters v0.0.0-20251117164519-53813c36cc1b/go.mod h1:OYRfF6eb5wY9VRFkXJH8FFBi3plw2v+giaIu7P054pM= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= @@ -300,36 +437,42 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= -github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU= +github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= -go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= -go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= -go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= -go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= @@ -340,90 +483,103 @@ go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4 go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= -golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= -google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= -google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= -lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE= -lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= +gvisor.dev/gvisor v0.0.0-20260408064518-65a410b0d584 h1:QyFROp5Ew7XZWKPtp8ap78z4gpY6xHpJIEdHgVA4bzA= +gvisor.dev/gvisor v0.0.0-20260408064518-65a410b0d584/go.mod h1:xQ2PWgHmWJA/Ph4i1q1jBm39BKhc3W0DXqWoDSyuBOY= +lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= +lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= +modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U= +modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8= +modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU= +modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c= +modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM= +modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/transport/masque/adapter.go b/transport/masque/adapter.go deleted file mode 100644 index f72748f7..00000000 --- a/transport/masque/adapter.go +++ /dev/null @@ -1,82 +0,0 @@ -package masque - -import ( - "sync" - - "github.com/sagernet/wireguard-go/tun" - "github.com/songgao/water" -) - -type NetstackAdapter struct { - dev tun.Device - tunnelBufPool sync.Pool - tunnelSizesPool sync.Pool -} - -func (n *NetstackAdapter) ReadPacket(buf []byte) (int, error) { - packetBufsPtr := n.tunnelBufPool.Get().(*[][]byte) - sizesPtr := n.tunnelSizesPool.Get().(*[]int) - - defer func() { - (*packetBufsPtr)[0] = nil - n.tunnelBufPool.Put(packetBufsPtr) - n.tunnelSizesPool.Put(sizesPtr) - }() - - (*packetBufsPtr)[0] = buf - (*sizesPtr)[0] = 0 - - _, err := n.dev.Read(*packetBufsPtr, *sizesPtr, 0) - if err != nil { - return 0, err - } - - return (*sizesPtr)[0], nil -} - -func (n *NetstackAdapter) WritePacket(pkt []byte) error { - // Write expects a slice of packet buffers. - _, err := n.dev.Write([][]byte{pkt}, 0) - return err -} - -// NewNetstackAdapter creates a new NetstackAdapter. -func NewNetstackAdapter(dev tun.Device) TunnelDevice { - return &NetstackAdapter{ - dev: dev, - tunnelBufPool: sync.Pool{ - New: func() interface{} { - buf := make([][]byte, 1) - return &buf - }, - }, - tunnelSizesPool: sync.Pool{ - New: func() interface{} { - sizes := make([]int, 1) - return &sizes - }, - }, - } -} - -type WaterAdapter struct { - iface *water.Interface -} - -func (w *WaterAdapter) ReadPacket(buf []byte) (int, error) { - n, err := w.iface.Read(buf) - if err != nil { - return 0, err - } - - return n, nil -} - -func (w *WaterAdapter) WritePacket(pkt []byte) error { - _, err := w.iface.Write(pkt) - return err -} - -func NewWaterAdapter(iface *water.Interface) TunnelDevice { - return &WaterAdapter{iface: iface} -} diff --git a/transport/masque/tunnel.go b/transport/masque/tunnel.go index f76e4599..0c2d9c6d 100644 --- a/transport/masque/tunnel.go +++ b/transport/masque/tunnel.go @@ -16,17 +16,11 @@ import ( M "github.com/sagernet/sing/common/metadata" ) -type TunnelDevice interface { - ReadPacket(buf []byte) (int, error) - WritePacket(pkt []byte) error -} - type Tunnel struct { - ctx context.Context - logger logger.ContextLogger - options TunnelOptions - tunDevice Device - tunnelDevice TunnelDevice + ctx context.Context + logger logger.ContextLogger + options TunnelOptions + device Device udpConn net.PacketConn tr *http3.Transport @@ -49,17 +43,16 @@ func NewTunnel(ctx context.Context, logger logger.ContextLogger, options TunnelO return nil, E.Cause(err, "create MASQUE device") } return &Tunnel{ - ctx: ctx, - logger: logger, - options: options, - tunDevice: tunDevice, - tunnelDevice: NewNetstackAdapter(tunDevice), + ctx: ctx, + logger: logger, + options: options, + device: tunDevice, }, nil } func (e *Tunnel) Start(resolve bool) error { if resolve { - err := e.tunDevice.Start() + err := e.device.Start() if err != nil { return err } @@ -72,14 +65,14 @@ func (e *Tunnel) DialContext(ctx context.Context, network string, destination M. if !destination.Addr.IsValid() { return nil, E.Cause(os.ErrInvalid, "invalid non-IP destination") } - return e.tunDevice.DialContext(ctx, network, destination) + return e.device.DialContext(ctx, network, destination) } func (e *Tunnel) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { if !destination.Addr.IsValid() { return nil, E.Cause(os.ErrInvalid, "invalid non-IP destination") } - return e.tunDevice.ListenPacket(ctx, destination) + return e.device.ListenPacket(ctx, destination) } func (e *Tunnel) Close() error { @@ -95,14 +88,16 @@ func (e *Tunnel) Close() error { } e.ipConn = nil } - return e.tunDevice.Close() + return e.device.Close() } func (e *Tunnel) maintainTunnel() { go func() { - buf := make([]byte, 1280) + bufs := make([][]byte, 1) + bufs[0] = make([]byte, 1280) + sizes := make([]int, 1) for e.ctx.Err() == nil { - n, err := e.tunnelDevice.ReadPacket(buf) + _, err := e.device.Read(bufs, sizes, 0) if err != nil { e.logger.ErrorContext(e.ctx, fmt.Errorf("failed to read from TUN device: %v", err)) continue @@ -111,7 +106,7 @@ func (e *Tunnel) maintainTunnel() { if err != nil { return } - icmp, err := ipConn.WritePacket(buf[:n]) + icmp, err := ipConn.WritePacket(bufs[0][:sizes[0]]) if err != nil { if errors.As(err, new(*connectip.CloseError)) { if ok := e.closeIpConn(ipConn); ok { @@ -123,7 +118,7 @@ func (e *Tunnel) maintainTunnel() { continue } if len(icmp) > 0 { - if err := e.tunnelDevice.WritePacket(icmp); err != nil { + if _, err := e.device.Write([][]byte{icmp}, 0); err != nil { if errors.As(err, new(*connectip.CloseError)) { e.logger.ErrorContext(e.ctx, fmt.Errorf("connection closed while writing ICMP to TUN device: %v", err)) continue @@ -151,7 +146,7 @@ func (e *Tunnel) maintainTunnel() { e.logger.ErrorContext(e.ctx, fmt.Errorf("Error reading from IP connection: %v, continuine...", err)) continue } - if err := e.tunnelDevice.WritePacket(buf[:n]); err != nil { + if _, err := e.device.Write([][]byte{buf[:n]}, 0); err != nil { continue } } diff --git a/transport/openvpn/cipher.go b/transport/openvpn/cipher.go new file mode 100644 index 00000000..3ec1dc57 --- /dev/null +++ b/transport/openvpn/cipher.go @@ -0,0 +1,227 @@ +package openvpn + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/binary" + "errors" + "hash" + + "golang.org/x/crypto/chacha20poly1305" +) + +const ( + AESGCMTagSize = 16 + AESGCMIVSize = 12 + CBCIVSize = aes.BlockSize +) + +type DataCipher interface { + Encrypt(header []byte, packetID uint32, payload []byte) ([]byte, error) + Decrypt(packet []byte, headerSize int) ([]byte, error) +} + +type AEADDataCipher struct { + send cipher.AEAD + recv cipher.AEAD + sendImplicitIV [AESGCMIVSize]byte + recvImplicitIV [AESGCMIVSize]byte +} + +func NewAEADCipher(keys *KeyMaterial, cipherName string) (*AEADDataCipher, error) { + var send, recv cipher.AEAD + var err error + if cipherName == CipherCHACHA20POLY { + send, err = chacha20poly1305.New(keys.SendCipherKey) + if err != nil { + return nil, err + } + recv, err = chacha20poly1305.New(keys.RecvCipherKey) + if err != nil { + return nil, err + } + } else { + sendBlock, err := aes.NewCipher(keys.SendCipherKey) + if err != nil { + return nil, err + } + recvBlock, err := aes.NewCipher(keys.RecvCipherKey) + if err != nil { + return nil, err + } + send, err = cipher.NewGCMWithTagSize(sendBlock, AESGCMTagSize) + if err != nil { + return nil, err + } + recv, err = cipher.NewGCMWithTagSize(recvBlock, AESGCMTagSize) + if err != nil { + return nil, err + } + } + if len(keys.SendHMACKey) < AESGCMIVSize-4 || len(keys.RecvHMACKey) < AESGCMIVSize-4 { + return nil, errors.New("openvpn implicit IV keys are too short") + } + g := &AEADDataCipher{send: send, recv: recv} + copy(g.sendImplicitIV[4:], keys.SendHMACKey[:AESGCMIVSize-4]) + copy(g.recvImplicitIV[4:], keys.RecvHMACKey[:AESGCMIVSize-4]) + return g, nil +} + +func (g *AEADDataCipher) Encrypt(header []byte, packetID uint32, payload []byte) ([]byte, error) { + var pidBytes [4]byte + binary.BigEndian.PutUint32(pidBytes[:], packetID) + nonce := g.nonce(packetID, g.sendImplicitIV) + ad := append(header, pidBytes[:]...) + sealed := g.send.Seal(nil, nonce[:], payload, ad) + out := make([]byte, 0, len(header)+4+len(sealed)) + out = append(out, header...) + out = append(out, pidBytes[:]...) + out = append(out, sealed[len(sealed)-AESGCMTagSize:]...) + out = append(out, sealed[:len(sealed)-AESGCMTagSize]...) + return out, nil +} + +func (g *AEADDataCipher) Decrypt(packet []byte, headerSize int) ([]byte, error) { + if len(packet) < headerSize+4+AESGCMTagSize+1 { + return nil, errors.New("openvpn gcm data packet too short") + } + header := packet[:headerSize] + pidBytes := packet[headerSize : headerSize+4] + tag := packet[headerSize+4 : headerSize+4+AESGCMTagSize] + ciphertext := packet[headerSize+4+AESGCMTagSize:] + combined := append(ciphertext, tag...) + ad := append(header, pidBytes...) + nonce := g.nonce(binary.BigEndian.Uint32(pidBytes), g.recvImplicitIV) + return g.recv.Open(nil, nonce[:], combined, ad) +} + +func (g *AEADDataCipher) nonce(packetID uint32, implicit [AESGCMIVSize]byte) [AESGCMIVSize]byte { + nonce := implicit + binary.BigEndian.PutUint32(nonce[:4], binary.BigEndian.Uint32(nonce[:4])^packetID) + return nonce +} + +type CBCDataCipher struct { + sendBlock cipher.Block + recvBlock cipher.Block + sendHMAC []byte + recvHMAC []byte + newHash func() hash.Hash + hmacSize int +} + +func NewCBCCipher(keys *KeyMaterial, auth string) (*CBCDataCipher, error) { + sendBlock, err := aes.NewCipher(keys.SendCipherKey) + if err != nil { + return nil, err + } + recvBlock, err := aes.NewCipher(keys.RecvCipherKey) + if err != nil { + return nil, err + } + var newHash func() hash.Hash + var hmacSize int + switch auth { + case AuthSHA256: + newHash = sha256.New + hmacSize = sha256.Size + case AuthSHA384: + newHash = sha512.New384 + hmacSize = 48 + case AuthSHA512: + newHash = sha512.New + hmacSize = sha512.Size + default: + newHash = sha1.New + hmacSize = sha1.Size + } + return &CBCDataCipher{ + sendBlock: sendBlock, + recvBlock: recvBlock, + sendHMAC: cloneBytes(keys.SendHMACKey[:hmacSize]), + recvHMAC: cloneBytes(keys.RecvHMACKey[:hmacSize]), + newHash: newHash, + hmacSize: hmacSize, + }, nil +} + +func (c *CBCDataCipher) Encrypt(header []byte, packetID uint32, payload []byte) ([]byte, error) { + var pidBytes [4]byte + binary.BigEndian.PutUint32(pidBytes[:], packetID) + plain := append(pidBytes[:], payload...) + padLen := aes.BlockSize - (len(plain) % aes.BlockSize) + for i := 0; i < padLen; i++ { + plain = append(plain, byte(padLen)) + } + iv := make([]byte, CBCIVSize) + if _, err := rand.Read(iv); err != nil { + return nil, err + } + ct := make([]byte, len(plain)) + cipher.NewCBCEncrypter(c.sendBlock, iv).CryptBlocks(ct, plain) + mac := hmac.New(c.newHash, c.sendHMAC) + mac.Write(iv) + mac.Write(ct) + tag := mac.Sum(nil) + out := make([]byte, 0, len(header)+c.hmacSize+CBCIVSize+len(ct)) + out = append(out, header...) + out = append(out, tag...) + out = append(out, iv...) + out = append(out, ct...) + return out, nil +} + +func (c *CBCDataCipher) Decrypt(packet []byte, headerSize int) ([]byte, error) { + minSize := headerSize + c.hmacSize + CBCIVSize + aes.BlockSize + if len(packet) < minSize { + return nil, errors.New("openvpn cbc data packet too short") + } + tag := packet[headerSize : headerSize+c.hmacSize] + iv := packet[headerSize+c.hmacSize : headerSize+c.hmacSize+CBCIVSize] + ct := packet[headerSize+c.hmacSize+CBCIVSize:] + if len(ct)%aes.BlockSize != 0 { + return nil, errors.New("openvpn cbc ciphertext not block-aligned") + } + mac := hmac.New(c.newHash, c.recvHMAC) + mac.Write(iv) + mac.Write(ct) + if !hmac.Equal(tag, mac.Sum(nil)) { + return nil, errors.New("openvpn cbc hmac verification failed") + } + plain := make([]byte, len(ct)) + cipher.NewCBCDecrypter(c.recvBlock, iv).CryptBlocks(plain, ct) + padLen := int(plain[len(plain)-1]) + if padLen < 1 || padLen > aes.BlockSize { + return nil, errors.New("openvpn cbc invalid padding") + } + plain = plain[:len(plain)-padLen] + if len(plain) < 4 { + return nil, errors.New("openvpn cbc payload too short") + } + return plain[4:], nil +} + +func CipherKeyLength(cipher string) int { + switch cipher { + case CipherAES128GCM, CipherAES128CBC: + return 16 + case CipherAES192GCM, CipherAES192CBC: + return 24 + default: + return 32 + } +} + +func IsAEAD(cipher string) bool { + switch cipher { + case CipherAES128GCM, CipherAES192GCM, CipherAES256GCM, CipherCHACHA20POLY: + return true + default: + return false + } +} diff --git a/transport/openvpn/client.go b/transport/openvpn/client.go new file mode 100644 index 00000000..e7adfa7e --- /dev/null +++ b/transport/openvpn/client.go @@ -0,0 +1,280 @@ +package openvpn + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "strings" + "time" + + "github.com/sagernet/sing/common/tls" +) + +const defaultHandshakeTimeout = 30 * time.Second + +type Client struct { + config *ClientConfig + tlsConfig tls.Config + mux *PacketMux + + control *ControlChannel + tlsConn tls.Conn + data *DataChannel + push *PushReply + + cancel context.CancelFunc +} + +func NewClient(config *ClientConfig, io PacketIO, tlsConfig tls.Config) (*Client, error) { + if config == nil { + return nil, errors.New("nil openvpn client config") + } + if io == nil { + return nil, errors.New("nil openvpn packet io") + } + if tlsConfig == nil { + return nil, errors.New("nil openvpn tls config") + } + var crypt ControlCrypt + var err error + if config.TLSAuthKey != nil { + crypt, err = NewTLSAuth(config.TLSAuthKey, config.KeyDirection, config.Auth) + if err != nil { + return nil, err + } + } else if config.TLSCryptKey != nil { + crypt, err = NewTLSCrypt(config.TLSCryptKey, true) + if err != nil { + return nil, err + } + } + local, err := NewSessionID() + if err != nil { + return nil, err + } + runCtx, cancel := context.WithCancel(context.Background()) + mux := NewPacketMux(io) + go mux.Run(runCtx) + return &Client{ + config: config, + tlsConfig: tlsConfig, + mux: mux, + control: NewControlChannel(mux, crypt, local), + cancel: cancel, + }, nil +} + +func (c *Client) Handshake(ctx context.Context) (*PushReply, error) { + if c == nil { + return nil, errors.New("nil openvpn client") + } + if _, ok := ctx.Deadline(); !ok { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, defaultHandshakeTimeout) + defer cancel() + } + if c.config.TLSCryptV2WKc != nil { + if err := c.sendResetV3(ctx); err != nil { + return nil, fmt.Errorf("send hard reset v3: %w", err) + } + } else { + if err := c.control.SendReset(ctx); err != nil { + return nil, fmt.Errorf("send hard reset: %w", err) + } + } + if err := c.waitServerReset(ctx); err != nil { + return nil, err + } + + controlConn := NewControlConn(c.control) + tlsConn, err := c.tlsConfig.Client(controlConn) + if err != nil { + return nil, fmt.Errorf("openvpn tls client: %w", err) + } + c.tlsConn = tlsConn + if err := c.tlsConn.HandshakeContext(ctx); err != nil { + return nil, fmt.Errorf("openvpn tls handshake: %w", err) + } + + clientRecord, err := NewClientKeyMethod2Record( + InstallScriptOptionsString(c.config.Proto, c.config.Cipher, c.config.Auth), + InstallScriptPeerInfo(c.config.Cipher), + strings.TrimSpace(c.config.Username), + c.config.Password, + ) + if err != nil { + return nil, err + } + clientBytes, err := clientRecord.MarshalClient() + if err != nil { + return nil, err + } + if _, err := c.tlsConn.Write(clientBytes); err != nil { + return nil, fmt.Errorf("write key method 2 client record: %w", err) + } + serverRecord, err := c.readServerKeyMethod(ctx) + if err != nil { + return nil, err + } + + sources := clientRecord.Sources + sources.Server = serverRecord.Sources.Server + keys, err := DeriveClientKeyMaterial(sources, c.control.LocalSessionID(), c.control.RemoteSessionID(), 32) + if err != nil { + return nil, fmt.Errorf("derive data channel keys: %w", err) + } + if _, err := c.tlsConn.Write([]byte(PushRequest + "\x00")); err != nil { + return nil, fmt.Errorf("write push request: %w", err) + } + push, err := c.readPushReply(ctx) + if err != nil { + return nil, err + } + c.push = push + dataCipher := c.config.Cipher + if push.Cipher != "" { + dataCipher = push.Cipher + } + if dataCipher == "" { + return nil, errors.New("openvpn server did not negotiate a cipher and no cipher configured") + } + keyLen := CipherKeyLength(dataCipher) + keys.SendCipherKey = keys.SendCipherKey[:keyLen] + keys.RecvCipherKey = keys.RecvCipherKey[:keyLen] + var cipher DataCipher + if IsAEAD(dataCipher) { + cipher, err = NewAEADCipher(keys, dataCipher) + } else { + cipher, err = NewCBCCipher(keys, c.config.Auth) + } + if err != nil { + return nil, err + } + c.data = NewDataChannel(cipher, push.PeerID, push.CompLZO) + return push, nil +} + +func (c *Client) WriteIPPacket(ctx context.Context, packet []byte) error { + if c.data == nil { + return errors.New("openvpn data channel is not ready") + } + encrypted, err := c.data.Encrypt(packet) + if err != nil { + return err + } + return c.mux.WritePacket(ctx, encrypted) +} + +func (c *Client) ReadIPPacket(ctx context.Context) ([]byte, error) { + if c.data == nil { + return nil, errors.New("openvpn data channel is not ready") + } + for { + packet, err := c.mux.ReadDataPacket(ctx) + if err != nil { + return nil, err + } + plain, err := c.data.Decrypt(packet) + if err != nil { + continue + } + return plain, nil + } +} + +func (c *Client) Close() error { + if c.cancel != nil { + c.cancel() + } + if c.tlsConn != nil { + _ = c.tlsConn.Close() + } + if c.mux != nil { + return c.mux.Close() + } + return nil +} + +func (c *Client) waitServerReset(ctx context.Context) error { + for { + packet, err := c.control.Read(ctx) + if err != nil { + return fmt.Errorf("read hard reset response: %w", err) + } + switch packet.Opcode { + case PControlHardResetServerV2: + return c.control.SendAck(ctx) + case PControlHardResetServerV1: + return fmt.Errorf("openvpn server replied with unsupported key method 1 reset") + } + } +} + +func (c *Client) readServerKeyMethod(ctx context.Context) (*KeyMethod2Record, error) { + var buf []byte + tmp := make([]byte, 4096) + for { + n, err := c.tlsConn.Read(tmp) + if err != nil { + return nil, fmt.Errorf("read key method 2 server record: %w", err) + } + buf = append(buf, tmp[:n]...) + record, err := ParseServerKeyMethod2Record(buf) + if err == nil { + return record, nil + } + if !strings.Contains(err.Error(), "truncated") && !errors.Is(err, ioStringEOF) { + return nil, err + } + } +} + +func (c *Client) readPushReply(ctx context.Context) (*PushReply, error) { + var buf []byte + tmp := make([]byte, 4096) + for { + n, err := c.tlsConn.Read(tmp) + if err != nil { + if errors.Is(err, io.EOF) && len(buf) > 0 { + break + } + return nil, fmt.Errorf("read push reply: %w", err) + } + buf = append(buf, tmp[:n]...) + if bytes.Contains(buf, []byte("\x00")) || strings.Contains(string(buf), "PUSH_REPLY") { + msg := string(buf) + if idx := strings.IndexByte(msg, 0); idx >= 0 { + msg = msg[:idx] + } + if reply, err := ParsePushReply(msg); err == nil { + return reply, nil + } + } + } + return nil, ctx.Err() +} + +func (c *Client) sendResetV3(ctx context.Context) error { + c.control.mu.Lock() + messageID := c.control.sendMessage + c.control.sendMessage++ + packet := &ControlPacket{ + Opcode: PControlHardResetClientV3, + KeyID: c.control.keyID, + LocalSession: c.control.local, + MessageID: messageID, + } + c.control.pending[messageID] = packet + c.control.mu.Unlock() + encoded, err := c.control.encodeAndWrap(ctx, packet) + if err != nil { + return err + } + encoded = append(encoded, c.config.TLSCryptV2WKc...) + return c.control.io.WritePacket(ctx, encoded) +} + +var _ net.Conn = (*ControlConn)(nil) diff --git a/transport/openvpn/config.go b/transport/openvpn/config.go new file mode 100644 index 00000000..55f9c895 --- /dev/null +++ b/transport/openvpn/config.go @@ -0,0 +1,175 @@ +package openvpn + +import ( + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strings" +) + +const ( + ProtoUDP = "udp" + ProtoTCP = "tcp" + + CipherAES128GCM = "AES-128-GCM" + CipherAES192GCM = "AES-192-GCM" + CipherAES256GCM = "AES-256-GCM" + CipherAES128CBC = "AES-128-CBC" + CipherAES192CBC = "AES-192-CBC" + CipherAES256CBC = "AES-256-CBC" + CipherCHACHA20POLY = "CHACHA20-POLY1305" + + AuthSHA1 = "SHA1" + AuthSHA256 = "SHA256" + AuthSHA384 = "SHA384" + AuthSHA512 = "SHA512" +) + +type ClientConfig struct { + Proto string + Cipher string + Auth string + Username string + Password string + KeyDirection int + + TLSCrypt []byte + TLSCryptV2 bool + TLSCryptKey []byte + TLSCryptV2WKc []byte + TLSAuthKey []byte +} + +func (c *ClientConfig) Prepare() error { + if c == nil { + return errors.New("nil openvpn client config") + } + c.Proto = normalizeProto(c.Proto) + c.Cipher = strings.ToUpper(strings.TrimSpace(c.Cipher)) + if c.Auth == "" { + c.Auth = AuthSHA1 + } + c.Auth = strings.ToUpper(strings.TrimSpace(c.Auth)) + if c.Proto != ProtoUDP && c.Proto != ProtoTCP { + return fmt.Errorf("unsupported openvpn proto %q: only udp and tcp are supported", c.Proto) + } + if c.Cipher != "" && !isValidCipher(c.Cipher) { + return fmt.Errorf("unsupported openvpn cipher %q", c.Cipher) + } + if !isValidAuth(c.Auth) { + return fmt.Errorf("unsupported openvpn auth %q", c.Auth) + } + if c.TLSCryptV2 { + kc, wkc, err := decodeTLSCryptV2Key(c.TLSCrypt) + if err != nil { + return fmt.Errorf("parse tls-crypt-v2 key: %w", err) + } + c.TLSCryptKey = kc + c.TLSCryptV2WKc = wkc + return nil + } + if len(strings.TrimSpace(string(c.TLSCrypt))) == 0 { + return nil + } + key, err := decodeStaticKey(c.TLSCrypt) + if err != nil { + return fmt.Errorf("parse tls key: %w", err) + } + if c.KeyDirection >= 0 { + c.TLSAuthKey = key + } else { + c.TLSCryptKey = key + } + return nil +} + +func normalizeProto(proto string) string { + switch strings.ToLower(strings.TrimSpace(proto)) { + case "", "udp", "udp4": + return ProtoUDP + case "tcp", "tcp-client", "tcp4", "tcp4-client": + return ProtoTCP + default: + return strings.ToLower(strings.TrimSpace(proto)) + } +} + +func isValidCipher(cipher string) bool { + switch cipher { + case CipherAES128GCM, CipherAES192GCM, CipherAES256GCM, + CipherAES128CBC, CipherAES192CBC, CipherAES256CBC, + CipherCHACHA20POLY: + return true + } + return false +} + +func isValidAuth(auth string) bool { + switch auth { + case AuthSHA1, AuthSHA256, AuthSHA384, AuthSHA512: + return true + } + return false +} + +func decodeStaticKey(block []byte) ([]byte, error) { + var hexLines []string + for _, raw := range strings.Split(string(block), "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "-----BEGIN OpenVPN Static key") || strings.HasPrefix(line, "-----END OpenVPN Static key") { + continue + } + hexLines = append(hexLines, line) + } + encoded := strings.Join(hexLines, "") + key, err := hex.DecodeString(encoded) + if err != nil { + return nil, err + } + if len(key) != 256 { + return nil, fmt.Errorf("invalid static key length %d, expected 256 bytes", len(key)) + } + return key, nil +} + +func decodeTLSCryptV2Key(block []byte) (kc []byte, wkc []byte, err error) { + data, err := decodePEM(block, "OpenVPN tls-crypt-v2 client key") + if err != nil { + return nil, nil, err + } + if len(data) < 256 { + return nil, nil, fmt.Errorf("tls-crypt-v2 key too short: %d bytes", len(data)) + } + return data[:256], data[256:], nil +} + +func decodePEM(block []byte, expectedHeader string) ([]byte, error) { + lines := strings.Split(string(block), "\n") + var b64 strings.Builder + inBlock := false + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.Contains(line, "BEGIN") && strings.Contains(line, expectedHeader) { + inBlock = true + continue + } + if strings.Contains(line, "END") && strings.Contains(line, expectedHeader) { + break + } + if inBlock { + b64.WriteString(line) + } + } + if b64.Len() == 0 { + return nil, fmt.Errorf("no %s block found", expectedHeader) + } + return base64Decode(b64.String()) +} + +func base64Decode(s string) ([]byte, error) { + return base64.StdEncoding.DecodeString(s) +} diff --git a/transport/openvpn/control.go b/transport/openvpn/control.go new file mode 100644 index 00000000..bdac9a76 --- /dev/null +++ b/transport/openvpn/control.go @@ -0,0 +1,475 @@ +package openvpn + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "sync" + "time" +) + +type PacketIO interface { + ReadPacket(ctx context.Context) ([]byte, error) + WritePacket(ctx context.Context, packet []byte) error + Close() error + LocalAddr() net.Addr + RemoteAddr() net.Addr +} + +type ControlChannel struct { + io PacketIO + encode func(*ControlPacket, uint32, uint32) ([]byte, error) + decode func([]byte) (*ControlPacket, uint32, uint32, error) + clock func() time.Time + keyID uint8 + local SessionID + remote SessionID + + mu sync.Mutex + sendPacketID uint32 + sendMessage uint32 + ackPending []uint32 + pending map[uint32]*ControlPacket + readDeadline time.Time + writeDeadline time.Time +} + +func NewControlChannel(io PacketIO, crypt ControlCrypt, local SessionID) *ControlChannel { + ch := &ControlChannel{ + io: io, + + clock: time.Now, + local: local, + pending: make(map[uint32]*ControlPacket), + } + if crypt != nil { + ch.encode = func(p *ControlPacket, pid uint32, t uint32) ([]byte, error) { + return EncodeControlPacketCrypt(*p, crypt, pid, t) + } + ch.decode = func(pkt []byte) (*ControlPacket, uint32, uint32, error) { + return DecodeControlPacketCrypt(crypt, pkt) + } + } else { + ch.encode = func(p *ControlPacket, _ uint32, _ uint32) ([]byte, error) { + return EncodeControlPacket(*p) + } + ch.decode = func(pkt []byte) (*ControlPacket, uint32, uint32, error) { + cp, err := DecodeControlPacket(pkt) + return cp, 0, 0, err + } + } + return ch +} + +func (c *ControlChannel) LocalSessionID() SessionID { + return c.local +} + +func (c *ControlChannel) RemoteSessionID() SessionID { + c.mu.Lock() + defer c.mu.Unlock() + return c.remote +} + +func (c *ControlChannel) SetRemoteSessionID(id SessionID) { + c.mu.Lock() + c.remote = id + c.mu.Unlock() +} + +func (c *ControlChannel) SendReset(ctx context.Context) error { + _, err := c.Send(ctx, PControlHardResetClientV2, nil) + return err +} + +func (c *ControlChannel) Send(ctx context.Context, opcode Opcode, payload []byte) (uint32, error) { + if !opcode.HasMessageID() { + return 0, fmt.Errorf("opcode %s cannot carry a reliable message", opcode) + } + c.mu.Lock() + messageID := c.sendMessage + c.sendMessage++ + packet := &ControlPacket{ + Opcode: opcode, + KeyID: c.keyID, + LocalSession: c.local, + AckIDs: append([]uint32(nil), c.ackPending...), + AckRemoteSession: c.remote, + MessageID: messageID, + Payload: cloneBytes(payload), + } + c.ackPending = nil + c.pending[messageID] = packet + c.mu.Unlock() + + if err := c.writeControlPacket(ctx, packet); err != nil { + return 0, err + } + return messageID, nil +} + +func (c *ControlChannel) SendAck(ctx context.Context) error { + c.mu.Lock() + if len(c.ackPending) == 0 { + c.mu.Unlock() + return nil + } + packet := &ControlPacket{ + Opcode: PAckV1, + KeyID: c.keyID, + LocalSession: c.local, + AckIDs: append([]uint32(nil), c.ackPending...), + AckRemoteSession: c.remote, + } + c.ackPending = nil + c.mu.Unlock() + return c.writeControlPacket(ctx, packet) +} + +func (c *ControlChannel) Read(ctx context.Context) (*ControlPacket, error) { + for { + packet, err := c.readControlPacket(ctx) + if err != nil { + return nil, err + } + c.mu.Lock() + if c.remote == (SessionID{}) && packet.LocalSession != c.local { + c.remote = packet.LocalSession + } + for _, ackID := range packet.AckIDs { + delete(c.pending, ackID) + } + if packet.Opcode.HasMessageID() { + c.ackPending = appendAck(c.ackPending, packet.MessageID) + } + c.mu.Unlock() + if packet.Opcode == PAckV1 { + continue + } + return packet, nil + } +} + +func (c *ControlChannel) PendingMessages() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.pending) +} + +func (c *ControlChannel) RetransmitPending(ctx context.Context) error { + c.mu.Lock() + packets := make([]*ControlPacket, 0, len(c.pending)) + for _, packet := range c.pending { + cp := *packet + cp.AckIDs = append([]uint32(nil), c.ackPending...) + cp.AckRemoteSession = c.remote + packets = append(packets, &cp) + } + c.ackPending = nil + c.mu.Unlock() + for _, packet := range packets { + if err := c.writeControlPacket(ctx, packet); err != nil { + return err + } + } + return nil +} + +func (c *ControlChannel) writeControlPacket(ctx context.Context, packet *ControlPacket) error { + c.mu.Lock() + c.sendPacketID++ + packetID := c.sendPacketID + unixTime := uint32(c.clock().Unix()) + deadline := c.writeDeadline + c.mu.Unlock() + if !deadline.IsZero() { + var cancel context.CancelFunc + ctx, cancel = context.WithDeadline(ctx, deadline) + defer cancel() + } + encoded, err := c.encode(packet, packetID, unixTime) + if err != nil { + return err + } + return c.io.WritePacket(ctx, encoded) +} + +func (c *ControlChannel) encodeAndWrap(ctx context.Context, packet *ControlPacket) ([]byte, error) { + c.mu.Lock() + c.sendPacketID++ + packetID := c.sendPacketID + unixTime := uint32(c.clock().Unix()) + c.mu.Unlock() + return c.encode(packet, packetID, unixTime) +} + +func (c *ControlChannel) readControlPacket(ctx context.Context) (*ControlPacket, error) { + c.mu.Lock() + deadline := c.readDeadline + c.mu.Unlock() + if !deadline.IsZero() { + var cancel context.CancelFunc + ctx, cancel = context.WithDeadline(ctx, deadline) + defer cancel() + } + raw, err := c.io.ReadPacket(ctx) + if err != nil { + return nil, err + } + packet, _, _, err := c.decode(raw) + return packet, err +} + +func (c *ControlChannel) SetDeadline(t time.Time) error { + c.mu.Lock() + c.readDeadline = t + c.writeDeadline = t + c.mu.Unlock() + return nil +} + +func (c *ControlChannel) SetReadDeadline(t time.Time) error { + c.mu.Lock() + c.readDeadline = t + c.mu.Unlock() + return nil +} + +func (c *ControlChannel) SetWriteDeadline(t time.Time) error { + c.mu.Lock() + c.writeDeadline = t + c.mu.Unlock() + return nil +} + +func appendAck(acks []uint32, ack uint32) []uint32 { + for _, existing := range acks { + if existing == ack { + return acks + } + } + return append(acks, ack) +} + +type ControlConn struct { + channel *ControlChannel + readBuf []byte + closed bool + mu sync.Mutex +} + +func NewControlConn(channel *ControlChannel) *ControlConn { + return &ControlConn{channel: channel} +} + +func (c *ControlConn) Read(b []byte) (int, error) { + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return 0, net.ErrClosed + } + if len(c.readBuf) > 0 { + n := copy(b, c.readBuf) + c.readBuf = c.readBuf[n:] + c.mu.Unlock() + return n, nil + } + c.mu.Unlock() + for { + packet, err := c.channel.Read(context.Background()) + if err != nil { + return 0, err + } + if packet.Opcode != PControlV1 { + if err := c.channel.SendAck(context.Background()); err != nil { + return 0, err + } + continue + } + if err := c.channel.SendAck(context.Background()); err != nil { + return 0, err + } + if len(packet.Payload) == 0 { + continue + } + n := copy(b, packet.Payload) + if n < len(packet.Payload) { + c.mu.Lock() + c.readBuf = append(c.readBuf, packet.Payload[n:]...) + c.mu.Unlock() + } + return n, nil + } +} + +func (c *ControlConn) Write(b []byte) (int, error) { + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return 0, net.ErrClosed + } + c.mu.Unlock() + if _, err := c.channel.Send(context.Background(), PControlV1, b); err != nil { + return 0, err + } + return len(b), nil +} + +func (c *ControlConn) Close() error { + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return nil + } + c.closed = true + c.mu.Unlock() + return c.channel.io.Close() +} + +func (c *ControlConn) LocalAddr() net.Addr { + return c.channel.io.LocalAddr() +} + +func (c *ControlConn) RemoteAddr() net.Addr { + return c.channel.io.RemoteAddr() +} + +func (c *ControlConn) SetDeadline(t time.Time) error { + return c.channel.SetDeadline(t) +} + +func (c *ControlConn) SetReadDeadline(t time.Time) error { + return c.channel.SetReadDeadline(t) +} + +func (c *ControlConn) SetWriteDeadline(t time.Time) error { + return c.channel.SetWriteDeadline(t) +} + +type streamPacketIO struct { + conn net.Conn +} + +type datagramPacketIO struct { + conn net.Conn +} + +func NewDatagramPacketIO(conn net.Conn) PacketIO { + return &datagramPacketIO{conn: conn} +} + +func (d *datagramPacketIO) ReadPacket(ctx context.Context) ([]byte, error) { + done := make(chan struct{}) + var ( + packet []byte + err error + ) + go func() { + defer close(done) + buf := make([]byte, 64*1024) + var n int + n, err = d.conn.Read(buf) + if err == nil { + packet = cloneBytes(buf[:n]) + } + }() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-done: + return packet, err + } +} + +func (d *datagramPacketIO) WritePacket(ctx context.Context, packet []byte) error { + done := make(chan error, 1) + go func() { + _, err := d.conn.Write(packet) + done <- err + }() + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-done: + return err + } +} + +func (d *datagramPacketIO) Close() error { + return d.conn.Close() +} + +func (d *datagramPacketIO) LocalAddr() net.Addr { + return d.conn.LocalAddr() +} + +func (d *datagramPacketIO) RemoteAddr() net.Addr { + return d.conn.RemoteAddr() +} + +func NewTCPPacketIO(conn net.Conn) PacketIO { + return &streamPacketIO{conn: conn} +} + +func (s *streamPacketIO) ReadPacket(ctx context.Context) ([]byte, error) { + done := make(chan struct{}) + var ( + packet []byte + err error + ) + go func() { + defer close(done) + var lenBuf [2]byte + if _, err = io.ReadFull(s.conn, lenBuf[:]); err != nil { + return + } + size := int(lenBuf[0])<<8 | int(lenBuf[1]) + if size == 0 { + err = errors.New("empty openvpn tcp packet") + return + } + packet = make([]byte, size) + _, err = io.ReadFull(s.conn, packet) + }() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-done: + return packet, err + } +} + +func (s *streamPacketIO) WritePacket(ctx context.Context, packet []byte) error { + if len(packet) > 0xffff { + return fmt.Errorf("openvpn tcp packet too large: %d", len(packet)) + } + done := make(chan error, 1) + go func() { + frame := make([]byte, 2+len(packet)) + frame[0] = byte(len(packet) >> 8) + frame[1] = byte(len(packet)) + copy(frame[2:], packet) + _, err := s.conn.Write(frame) + done <- err + }() + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-done: + return err + } +} + +func (s *streamPacketIO) Close() error { + return s.conn.Close() +} + +func (s *streamPacketIO) LocalAddr() net.Addr { + return s.conn.LocalAddr() +} + +func (s *streamPacketIO) RemoteAddr() net.Addr { + return s.conn.RemoteAddr() +} diff --git a/transport/openvpn/data.go b/transport/openvpn/data.go new file mode 100644 index 00000000..521296e3 --- /dev/null +++ b/transport/openvpn/data.go @@ -0,0 +1,91 @@ +package openvpn + +import ( + "errors" + "fmt" + "sync" +) + +const ( + PeerIDUnset uint32 = 0xffffff +) + +type DataChannel struct { + cipher DataCipher + keyID uint8 + peerID uint32 + compLZO bool + mu sync.Mutex + sendPacketID uint32 +} + +func NewDataChannel(cipher DataCipher, peerID uint32, compLZO bool) *DataChannel { + return &DataChannel{ + cipher: cipher, + peerID: peerID, + compLZO: compLZO, + } +} + +func (d *DataChannel) Encrypt(packet []byte) ([]byte, error) { + if d.compLZO { + p := make([]byte, 1+len(packet)) + p[0] = 0xFA + copy(p[1:], packet) + packet = p + } + d.mu.Lock() + d.sendPacketID++ + packetID := d.sendPacketID + d.mu.Unlock() + return d.cipher.Encrypt(d.dataHeader(), packetID, packet) +} + +func (d *DataChannel) Decrypt(packet []byte) ([]byte, error) { + if len(packet) < 1 { + return nil, errors.New("empty openvpn data packet") + } + opcode, _ := parseOpcodeKeyID(packet[0]) + headerSize := 1 + if opcode == PDataV2 { + headerSize = 4 + } + plain, err := d.cipher.Decrypt(packet, headerSize) + if err != nil { + return nil, err + } + if d.compLZO { + if len(plain) < 1 { + return nil, errors.New("openvpn comp-lzo packet too short") + } + if plain[0] != 0xFA { + return nil, fmt.Errorf("openvpn compressed packet not supported (byte: 0x%02x)", plain[0]) + } + plain = plain[1:] + } + return plain, nil +} + +func (d *DataChannel) dataHeader() []byte { + if d.peerID != PeerIDUnset { + return []byte{ + opcodeKeyID(PDataV2, d.keyID), + byte(d.peerID >> 16), + byte(d.peerID >> 8), + byte(d.peerID), + } + } + return []byte{opcodeKeyID(PDataV1, d.keyID)} +} + +func ParsePeerID(options string) uint32 { + for _, field := range splitPushOptions(options) { + if len(field) > len("peer-id ") && field[:len("peer-id ")] == "peer-id " { + var id uint32 + if _, err := fmt.Sscanf(field, "peer-id %d", &id); err == nil && id <= PeerIDUnset { + return id + } + } + } + return PeerIDUnset +} diff --git a/transport/openvpn/device.go b/transport/openvpn/device.go new file mode 100644 index 00000000..bc3ac83c --- /dev/null +++ b/transport/openvpn/device.go @@ -0,0 +1,33 @@ +package openvpn + +import ( + "context" + "net/netip" + "time" + + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" + wgTun "github.com/sagernet/wireguard-go/tun" +) + +type Device interface { + wgTun.Device + N.Dialer + Start() error +} + +type DeviceOptions struct { + Context context.Context + Logger logger.ContextLogger + Handler tun.Handler + UDPTimeout time.Duration + CreateDialer func(interfaceName string) N.Dialer + Name string + MTU uint32 + Address []netip.Prefix +} + +func NewDevice(options DeviceOptions) (Device, error) { + return newStackDevice(options) +} diff --git a/transport/openvpn/device_stack.go b/transport/openvpn/device_stack.go new file mode 100644 index 00000000..578e58ec --- /dev/null +++ b/transport/openvpn/device_stack.go @@ -0,0 +1,309 @@ +//go:build with_gvisor + +package openvpn + +import ( + "context" + "net" + "net/netip" + "os" + + "github.com/sagernet/gvisor/pkg/buffer" + "github.com/sagernet/gvisor/pkg/tcpip" + "github.com/sagernet/gvisor/pkg/tcpip/adapters/gonet" + "github.com/sagernet/gvisor/pkg/tcpip/header" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv4" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv6" + "github.com/sagernet/gvisor/pkg/tcpip/stack" + "github.com/sagernet/gvisor/pkg/tcpip/transport/icmp" + "github.com/sagernet/gvisor/pkg/tcpip/transport/tcp" + "github.com/sagernet/gvisor/pkg/tcpip/transport/udp" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/transport/wireguard" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + wgTun "github.com/sagernet/wireguard-go/tun" +) + +type stackDevice struct { + ctx context.Context + logger log.ContextLogger + stack *stack.Stack + mtu uint32 + events chan wgTun.Event + wgTun.Device + outbound chan *stack.PacketBuffer + packetOutbound chan *buf.Buffer + done chan struct{} + dispatcher stack.NetworkDispatcher + inet4Address netip.Addr + inet6Address netip.Addr +} + +func newStackDevice(options DeviceOptions) (*stackDevice, error) { + tunDevice := &stackDevice{ + ctx: options.Context, + logger: options.Logger, + mtu: options.MTU, + events: make(chan wgTun.Event, 1), + outbound: make(chan *stack.PacketBuffer, 256), + packetOutbound: make(chan *buf.Buffer, 256), + done: make(chan struct{}), + } + ipStack, err := tun.NewGVisorStackWithOptions((*wireEndpoint)(tunDevice), stack.NICOptions{}, true) + if err != nil { + return nil, err + } + var ( + inet4Address netip.Addr + inet6Address netip.Addr + ) + for _, prefix := range options.Address { + addr := tun.AddressFromAddr(prefix.Addr()) + protoAddr := tcpip.ProtocolAddress{ + AddressWithPrefix: tcpip.AddressWithPrefix{ + Address: addr, + PrefixLen: prefix.Bits(), + }, + } + if prefix.Addr().Is4() { + inet4Address = prefix.Addr() + tunDevice.inet4Address = inet4Address + protoAddr.Protocol = ipv4.ProtocolNumber + } else { + inet6Address = prefix.Addr() + tunDevice.inet6Address = inet6Address + protoAddr.Protocol = ipv6.ProtocolNumber + } + gErr := ipStack.AddProtocolAddress(tun.DefaultNIC, protoAddr, stack.AddressProperties{}) + if gErr != nil { + return nil, E.New("parse local address ", protoAddr.AddressWithPrefix, ": ", gErr.String()) + } + } + tunDevice.stack = ipStack + if options.Handler != nil { + ipStack.SetTransportProtocolHandler(tcp.ProtocolNumber, tun.NewTCPForwarder(options.Context, ipStack, options.Handler).HandlePacket) + ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, tun.NewUDPForwarder(options.Context, ipStack, options.Handler, options.UDPTimeout).HandlePacket) + icmpForwarder := tun.NewICMPForwarder(options.Context, ipStack, options.Handler, options.UDPTimeout) + ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket) + ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket) + } + return tunDevice, nil +} + +func (w *stackDevice) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + addr := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + Port: destination.Port, + Addr: tun.AddressFromAddr(destination.Addr), + } + bind := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + } + var networkProtocol tcpip.NetworkProtocolNumber + if destination.IsIPv4() { + if !w.inet4Address.IsValid() { + return nil, E.New("missing IPv4 local address") + } + networkProtocol = header.IPv4ProtocolNumber + bind.Addr = tun.AddressFromAddr(w.inet4Address) + } else { + if !w.inet6Address.IsValid() { + return nil, E.New("missing IPv6 local address") + } + networkProtocol = header.IPv6ProtocolNumber + bind.Addr = tun.AddressFromAddr(w.inet6Address) + } + switch N.NetworkName(network) { + case N.NetworkTCP: + tcpConn, err := wireguard.DialTCPWithBind(ctx, w.stack, bind, addr, networkProtocol) + if err != nil { + return nil, err + } + return tcpConn, nil + case N.NetworkUDP: + udpConn, err := gonet.DialUDP(w.stack, &bind, &addr, networkProtocol) + if err != nil { + return nil, err + } + return udpConn, nil + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } +} + +func (w *stackDevice) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + bind := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + } + var networkProtocol tcpip.NetworkProtocolNumber + if destination.IsIPv4() { + networkProtocol = header.IPv4ProtocolNumber + bind.Addr = tun.AddressFromAddr(w.inet4Address) + } else { + networkProtocol = header.IPv6ProtocolNumber + bind.Addr = tun.AddressFromAddr(w.inet6Address) + } + udpConn, err := gonet.DialUDP(w.stack, &bind, nil, networkProtocol) + if err != nil { + return nil, err + } + return udpConn, nil +} + +func (w *stackDevice) Start() error { + w.events <- wgTun.EventUp + return nil +} + +func (w *stackDevice) File() *os.File { + return nil +} + +func (w *stackDevice) Read(bufs [][]byte, sizes []int, offset int) (count int, err error) { + select { + case packet, ok := <-w.outbound: + if !ok { + return 0, os.ErrClosed + } + defer packet.DecRef() + var copyN int + /*rangeIterate(packet.Data().AsRange(), func(view *buffer.View) { + copyN += copy(bufs[0][offset+copyN:], view.AsSlice()) + })*/ + for _, view := range packet.AsSlices() { + copyN += copy(bufs[0][offset+copyN:], view) + } + sizes[0] = copyN + return 1, nil + case packet := <-w.packetOutbound: + defer packet.Release() + sizes[0] = copy(bufs[0][offset:], packet.Bytes()) + return 1, nil + case <-w.done: + return 0, os.ErrClosed + } +} + +func (w *stackDevice) Write(bufs [][]byte, offset int) (count int, err error) { + for _, b := range bufs { + b = b[offset:] + if len(b) == 0 { + continue + } + var networkProtocol tcpip.NetworkProtocolNumber + switch header.IPVersion(b) { + case header.IPv4Version: + networkProtocol = header.IPv4ProtocolNumber + case header.IPv6Version: + networkProtocol = header.IPv6ProtocolNumber + } + packetBuffer := stack.NewPacketBuffer(stack.PacketBufferOptions{ + Payload: buffer.MakeWithData(b), + }) + w.dispatcher.DeliverNetworkPacket(networkProtocol, packetBuffer) + packetBuffer.DecRef() + count++ + } + return +} + +func (w *stackDevice) Flush() error { + return nil +} + +func (w *stackDevice) MTU() (int, error) { + return int(w.mtu), nil +} + +func (w *stackDevice) Name() (string, error) { + return "sing-box", nil +} + +func (w *stackDevice) Events() <-chan wgTun.Event { + return w.events +} + +func (w *stackDevice) Close() error { + close(w.done) + close(w.events) + w.stack.Close() + for _, endpoint := range w.stack.CleanupEndpoints() { + endpoint.Abort() + } + w.stack.Wait() + return nil +} + +func (w *stackDevice) BatchSize() int { + return 1 +} + +var _ stack.LinkEndpoint = (*wireEndpoint)(nil) + +type wireEndpoint stackDevice + +func (ep *wireEndpoint) MTU() uint32 { + return ep.mtu +} + +func (ep *wireEndpoint) SetMTU(mtu uint32) { +} + +func (ep *wireEndpoint) MaxHeaderLength() uint16 { + return 0 +} + +func (ep *wireEndpoint) LinkAddress() tcpip.LinkAddress { + return "" +} + +func (ep *wireEndpoint) SetLinkAddress(addr tcpip.LinkAddress) { +} + +func (ep *wireEndpoint) Capabilities() stack.LinkEndpointCapabilities { + return stack.CapabilityRXChecksumOffload +} + +func (ep *wireEndpoint) Attach(dispatcher stack.NetworkDispatcher) { + ep.dispatcher = dispatcher +} + +func (ep *wireEndpoint) IsAttached() bool { + return ep.dispatcher != nil +} + +func (ep *wireEndpoint) Wait() { +} + +func (ep *wireEndpoint) ARPHardwareType() header.ARPHardwareType { + return header.ARPHardwareNone +} + +func (ep *wireEndpoint) AddHeader(buffer *stack.PacketBuffer) { +} + +func (ep *wireEndpoint) ParseHeader(ptr *stack.PacketBuffer) bool { + return true +} + +func (ep *wireEndpoint) WritePackets(list stack.PacketBufferList) (int, tcpip.Error) { + for _, packetBuffer := range list.AsSlice() { + packetBuffer.IncRef() + select { + case <-ep.done: + return 0, &tcpip.ErrClosedForSend{} + case ep.outbound <- packetBuffer: + } + } + return list.Len(), nil +} + +func (ep *wireEndpoint) Close() { +} + +func (ep *wireEndpoint) SetOnCloseAction(f func()) { +} diff --git a/transport/openvpn/device_stack_stub.go b/transport/openvpn/device_stack_stub.go new file mode 100644 index 00000000..85c5debe --- /dev/null +++ b/transport/openvpn/device_stack_stub.go @@ -0,0 +1,13 @@ +//go:build !with_gvisor + +package openvpn + +import "github.com/sagernet/sing-tun" + +func newStackDevice(options DeviceOptions) (Device, error) { + return nil, tun.ErrGVisorNotIncluded +} + +func newSystemStackDevice(options DeviceOptions) (Device, error) { + return nil, tun.ErrGVisorNotIncluded +} diff --git a/transport/openvpn/keymethod.go b/transport/openvpn/keymethod.go new file mode 100644 index 00000000..4e74cdd3 --- /dev/null +++ b/transport/openvpn/keymethod.go @@ -0,0 +1,250 @@ +package openvpn + +import ( + "crypto/hmac" + "crypto/md5" + "crypto/rand" + "crypto/sha1" + "encoding/binary" + "errors" + "fmt" + "hash" +) + +const ( + KeyMethod2 = 2 + + keySourcePreMasterSize = 48 + keySourceRandomSize = 32 + + maxCipherKeyLength = 64 + maxHMACKeyLength = 64 + keyBlockSize = 2 * (maxCipherKeyLength + maxHMACKeyLength) + + keyExpansionID = "OpenVPN" +) + +type KeySource struct { + PreMaster [keySourcePreMasterSize]byte + Random1 [keySourceRandomSize]byte + Random2 [keySourceRandomSize]byte +} + +type KeySource2 struct { + Client KeySource + Server KeySource +} + +type KeyMaterial struct { + SendCipherKey []byte + SendHMACKey []byte + RecvCipherKey []byte + RecvHMACKey []byte +} + +type KeyMethod2Record struct { + Sources KeySource2 + Options string + Username string + Password string + PeerInfo string +} + +func NewClientKeyMethod2Record(options, peerInfo, username, password string) (*KeyMethod2Record, error) { + var record KeyMethod2Record + if _, err := rand.Read(record.Sources.Client.PreMaster[:]); err != nil { + return nil, err + } + if _, err := rand.Read(record.Sources.Client.Random1[:]); err != nil { + return nil, err + } + if _, err := rand.Read(record.Sources.Client.Random2[:]); err != nil { + return nil, err + } + record.Options = options + record.PeerInfo = peerInfo + record.Username = username + record.Password = password + return &record, nil +} + +func (r *KeyMethod2Record) MarshalClient() ([]byte, error) { + if r == nil { + return nil, errors.New("nil key method 2 record") + } + out := make([]byte, 0, 4+1+keySourcePreMasterSize+keySourceRandomSize*2+len(r.Options)+16) + out = binary.BigEndian.AppendUint32(out, 0) + out = append(out, KeyMethod2) + out = append(out, r.Sources.Client.PreMaster[:]...) + out = append(out, r.Sources.Client.Random1[:]...) + out = append(out, r.Sources.Client.Random2[:]...) + out = appendOpenVPNString(out, r.Options) + out = appendOpenVPNString(out, r.Username) + out = appendOpenVPNString(out, r.Password) + out = appendOpenVPNString(out, r.PeerInfo) + return out, nil +} + +func ParseServerKeyMethod2Record(packet []byte) (*KeyMethod2Record, error) { + if len(packet) < 4+1+keySourceRandomSize*2 { + return nil, errors.New("key method 2 packet too short") + } + if binary.BigEndian.Uint32(packet[:4]) != 0 { + return nil, errors.New("invalid key method 2 prefix") + } + if packet[4]&0x0f != KeyMethod2 { + return nil, fmt.Errorf("unsupported key method %d", packet[4]) + } + offset := 5 + record := &KeyMethod2Record{} + copy(record.Sources.Server.Random1[:], packet[offset:offset+keySourceRandomSize]) + offset += keySourceRandomSize + copy(record.Sources.Server.Random2[:], packet[offset:offset+keySourceRandomSize]) + offset += keySourceRandomSize + + var err error + record.Options, offset, err = readOpenVPNString(packet, offset) + if err != nil { + return nil, fmt.Errorf("read options: %w", err) + } + record.Username, offset, _ = readOpenVPNString(packet, offset) + record.Password, offset, _ = readOpenVPNString(packet, offset) + record.PeerInfo, _, _ = readOpenVPNString(packet, offset) + return record, nil +} + +func DeriveClientKeyMaterial(sources KeySource2, clientSession, serverSession SessionID, cipherKeyLen int) (*KeyMaterial, error) { + if cipherKeyLen != 16 && cipherKeyLen != 32 { + return nil, fmt.Errorf("unsupported data cipher key length %d", cipherKeyLen) + } + var master [48]byte + if err := openvpnPRF( + sources.Client.PreMaster[:], + keyExpansionID+" master secret", + sources.Client.Random1[:], + sources.Server.Random1[:], + nil, + nil, + master[:], + ); err != nil { + return nil, err + } + + keyBlock := make([]byte, keyBlockSize) + if err := openvpnPRF( + master[:], + keyExpansionID+" key expansion", + sources.Client.Random2[:], + sources.Server.Random2[:], + clientSession[:], + serverSession[:], + keyBlock, + ); err != nil { + return nil, err + } + + clientToServer := keyBlock[:maxCipherKeyLength+maxHMACKeyLength] + serverToClient := keyBlock[maxCipherKeyLength+maxHMACKeyLength:] + return &KeyMaterial{ + SendCipherKey: cloneBytes(clientToServer[:cipherKeyLen]), + SendHMACKey: cloneBytes(clientToServer[maxCipherKeyLength : maxCipherKeyLength+maxHMACKeyLength]), + RecvCipherKey: cloneBytes(serverToClient[:cipherKeyLen]), + RecvHMACKey: cloneBytes(serverToClient[maxCipherKeyLength : maxCipherKeyLength+maxHMACKeyLength]), + }, nil +} + +func InstallScriptOptionsString(proto, cipher, auth string) string { + protoName := "UDPv4" + if proto == ProtoTCP { + protoName = "TCPv4_CLIENT" + } + keysize := "128" + switch cipher { + case CipherAES192GCM, CipherAES192CBC: + keysize = "192" + case CipherAES256GCM, CipherAES256CBC, CipherCHACHA20POLY: + keysize = "256" + } + return fmt.Sprintf("V4,dev-type tun,link-mtu 1550,tun-mtu 1500,proto %s,cipher %s,auth %s,keysize %s,key-method 2,tls-client", protoName, cipher, auth, keysize) +} + +func InstallScriptPeerInfo(cipher string) string { + if cipher != "" { + return "IV_VER=sing-box-openvpn\nIV_PROTO=6\nIV_CIPHERS=" + cipher + "\n" + } + return "IV_VER=sing-box-openvpn\nIV_PROTO=6\nIV_CIPHERS=AES-256-GCM:AES-192-GCM:AES-128-GCM:AES-256-CBC:AES-192-CBC:AES-128-CBC:CHACHA20-POLY1305\n" +} + +func appendOpenVPNString(out []byte, s string) []byte { + if s == "" { + return binary.BigEndian.AppendUint16(out, 0) + } + if len(s)+1 > 0xffff { + s = s[:0xfffe] + } + out = binary.BigEndian.AppendUint16(out, uint16(len(s)+1)) + out = append(out, s...) + out = append(out, 0) + return out +} + +func readOpenVPNString(packet []byte, offset int) (string, int, error) { + if offset+2 > len(packet) { + return "", offset, ioStringEOF + } + size := int(binary.BigEndian.Uint16(packet[offset : offset+2])) + offset += 2 + if size == 0 { + return "", offset, nil + } + if offset+size > len(packet) { + return "", offset, ioStringEOF + } + raw := packet[offset : offset+size] + offset += size + if raw[len(raw)-1] == 0 { + raw = raw[:len(raw)-1] + } + return string(raw), offset, nil +} + +var ioStringEOF = errors.New("openvpn string truncated") + +func openvpnPRF(secret []byte, label string, clientSeed, serverSeed, clientSession, serverSession []byte, out []byte) error { + seed := make([]byte, 0, len(label)+len(clientSeed)+len(serverSeed)+len(clientSession)+len(serverSession)) + seed = append(seed, label...) + seed = append(seed, clientSeed...) + seed = append(seed, serverSeed...) + seed = append(seed, clientSession...) + seed = append(seed, serverSession...) + + split := (len(secret) + 1) / 2 + s1 := secret[:split] + s2 := secret[len(secret)-split:] + + md5Out := pHash(md5.New, s1, seed, len(out)) + sha1Out := pHash(sha1.New, s2, seed, len(out)) + for i := range out { + out[i] = md5Out[i] ^ sha1Out[i] + } + return nil +} + +func pHash(newHash func() hash.Hash, secret, seed []byte, size int) []byte { + out := make([]byte, 0, size) + a := hmacSum(newHash, secret, seed) + for len(out) < size { + chunkInput := make([]byte, 0, len(a)+len(seed)) + chunkInput = append(chunkInput, a...) + chunkInput = append(chunkInput, seed...) + out = append(out, hmacSum(newHash, secret, chunkInput)...) + a = hmacSum(newHash, secret, a) + } + return out[:size] +} + +func hmacSum(newHash func() hash.Hash, key, data []byte) []byte { + mac := hmac.New(newHash, key) + _, _ = mac.Write(data) + return mac.Sum(nil) +} diff --git a/transport/openvpn/mux.go b/transport/openvpn/mux.go new file mode 100644 index 00000000..b5966308 --- /dev/null +++ b/transport/openvpn/mux.go @@ -0,0 +1,92 @@ +package openvpn + +import ( + "context" + "net" + "sync" +) + +type PacketMux struct { + io PacketIO + + control chan []byte + data chan []byte + done chan struct{} + once sync.Once +} + +func NewPacketMux(io PacketIO) *PacketMux { + return &PacketMux{ + io: io, + control: make(chan []byte, 64), + data: make(chan []byte, 256), + done: make(chan struct{}), + } +} + +func (m *PacketMux) Run(ctx context.Context) { + defer m.Close() + for ctx.Err() == nil { + packet, err := m.io.ReadPacket(ctx) + if err != nil { + return + } + if len(packet) == 0 { + continue + } + opcode, _ := parseOpcodeKeyID(packet[0]) + ch := m.data + if opcode.IsControl() { + ch = m.control + } + select { + case ch <- packet: + case <-ctx.Done(): + return + case <-m.done: + return + } + } +} + +func (m *PacketMux) ReadPacket(ctx context.Context) ([]byte, error) { + select { + case packet := <-m.control: + return packet, nil + case <-ctx.Done(): + return nil, ctx.Err() + case <-m.done: + return nil, net.ErrClosed + } +} + +func (m *PacketMux) ReadDataPacket(ctx context.Context) ([]byte, error) { + select { + case packet := <-m.data: + return packet, nil + case <-ctx.Done(): + return nil, ctx.Err() + case <-m.done: + return nil, net.ErrClosed + } +} + +func (m *PacketMux) WritePacket(ctx context.Context, packet []byte) error { + return m.io.WritePacket(ctx, packet) +} + +func (m *PacketMux) Close() error { + m.once.Do(func() { + close(m.done) + _ = m.io.Close() + }) + return nil +} + +func (m *PacketMux) LocalAddr() net.Addr { + return m.io.LocalAddr() +} + +func (m *PacketMux) RemoteAddr() net.Addr { + return m.io.RemoteAddr() +} diff --git a/transport/openvpn/packet.go b/transport/openvpn/packet.go new file mode 100644 index 00000000..adcc2c98 --- /dev/null +++ b/transport/openvpn/packet.go @@ -0,0 +1,254 @@ +package openvpn + +import ( + "crypto/rand" + "encoding/binary" + "errors" + "fmt" +) + +type ControlCrypt interface { + Wrap(header []byte, packetID uint32, unixTime uint32, plaintext []byte) ([]byte, error) + Unwrap(packet []byte) (header []byte, packetID uint32, unixTime uint32, plaintext []byte, err error) +} + +const ( + KeyIDMask = 0x07 + OpcodeShift = 3 + + PControlHardResetClientV1 Opcode = 1 + PControlHardResetServerV1 Opcode = 2 + PControlSoftResetV1 Opcode = 3 + PControlV1 Opcode = 4 + PAckV1 Opcode = 5 + PDataV1 Opcode = 6 + PControlHardResetClientV2 Opcode = 7 + PControlHardResetServerV2 Opcode = 8 + PDataV2 Opcode = 9 + PControlHardResetClientV3 Opcode = 10 + PControlWKCV1 Opcode = 11 + + SessionIDSize = 8 +) + +type Opcode uint8 + +func (o Opcode) String() string { + switch o { + case PControlHardResetClientV1: + return "P_CONTROL_HARD_RESET_CLIENT_V1" + case PControlHardResetServerV1: + return "P_CONTROL_HARD_RESET_SERVER_V1" + case PControlSoftResetV1: + return "P_CONTROL_SOFT_RESET_V1" + case PControlV1: + return "P_CONTROL_V1" + case PAckV1: + return "P_ACK_V1" + case PDataV1: + return "P_DATA_V1" + case PControlHardResetClientV2: + return "P_CONTROL_HARD_RESET_CLIENT_V2" + case PControlHardResetServerV2: + return "P_CONTROL_HARD_RESET_SERVER_V2" + case PDataV2: + return "P_DATA_V2" + case PControlHardResetClientV3: + return "P_CONTROL_HARD_RESET_CLIENT_V3" + case PControlWKCV1: + return "P_CONTROL_WKC_V1" + default: + return "P_UNKNOWN" + } +} + +func (o Opcode) IsControl() bool { + switch o { + case PControlHardResetClientV1, PControlHardResetServerV1, PControlSoftResetV1, PControlV1, + PAckV1, PControlHardResetClientV2, PControlHardResetServerV2, PControlHardResetClientV3, PControlWKCV1: + return true + default: + return false + } +} + +func (o Opcode) HasMessageID() bool { + return o.IsControl() && o != PAckV1 +} + +type SessionID [SessionIDSize]byte + +func NewSessionID() (SessionID, error) { + var id SessionID + _, err := rand.Read(id[:]) + return id, err +} + +type ControlPacket struct { + Opcode Opcode + KeyID uint8 + LocalSession SessionID + + AckIDs []uint32 + AckRemoteSession SessionID + + MessageID uint32 + Payload []byte +} + +func opcodeKeyID(opcode Opcode, keyID uint8) byte { + return byte(opcode)<> OpcodeShift), b & KeyIDMask +} + +func EncodeControlPlain(p ControlPacket) ([]byte, error) { + if !p.Opcode.IsControl() { + return nil, fmt.Errorf("opcode %s is not a control opcode", p.Opcode) + } + if len(p.AckIDs) > 255 { + return nil, fmt.Errorf("too many ack ids: %d", len(p.AckIDs)) + } + + size := 1 + len(p.AckIDs)*4 + if len(p.AckIDs) > 0 { + size += SessionIDSize + } + if p.Opcode.HasMessageID() { + size += 4 + len(p.Payload) + } + out := make([]byte, 0, size) + out = append(out, byte(len(p.AckIDs))) + for _, id := range p.AckIDs { + var b [4]byte + binary.BigEndian.PutUint32(b[:], id) + out = append(out, b[:]...) + } + if len(p.AckIDs) > 0 { + out = append(out, p.AckRemoteSession[:]...) + } + if p.Opcode.HasMessageID() { + var b [4]byte + binary.BigEndian.PutUint32(b[:], p.MessageID) + out = append(out, b[:]...) + out = append(out, p.Payload...) + } + return out, nil +} + +func EncodeControlPacket(p ControlPacket) ([]byte, error) { + plain, err := EncodeControlPlain(p) + if err != nil { + return nil, err + } + header := make([]byte, TLSCryptHeaderSize) + header[0] = opcodeKeyID(p.Opcode, p.KeyID) + copy(header[1:], p.LocalSession[:]) + out := make([]byte, 0, len(header)+len(plain)) + out = append(out, header...) + out = append(out, plain...) + return out, nil +} + +func EncodeControlPacketCrypt(p ControlPacket, crypt ControlCrypt, packetID uint32, unixTime uint32) ([]byte, error) { + plain, err := EncodeControlPlain(p) + if err != nil { + return nil, err + } + header := make([]byte, TLSCryptHeaderSize) + header[0] = opcodeKeyID(p.Opcode, p.KeyID) + copy(header[1:], p.LocalSession[:]) + return crypt.Wrap(header, packetID, unixTime, plain) +} + +func DecodeControlPacket(packet []byte) (*ControlPacket, error) { + if len(packet) < TLSCryptHeaderSize { + return nil, errors.New("control packet too short") + } + opcode, keyID := parseOpcodeKeyID(packet[0]) + if !opcode.IsControl() { + return nil, fmt.Errorf("opcode %s is not a control opcode", opcode) + } + var local SessionID + copy(local[:], packet[1:]) + plain := packet[TLSCryptHeaderSize:] + ackIDs, ackRemote, messageID, payload, err := DecodeControlPlain(opcode, plain) + if err != nil { + return nil, err + } + return &ControlPacket{ + Opcode: opcode, + KeyID: keyID, + LocalSession: local, + AckIDs: ackIDs, + AckRemoteSession: ackRemote, + MessageID: messageID, + Payload: payload, + }, nil +} + +func DecodeControlPacketCrypt(crypt ControlCrypt, packet []byte) (*ControlPacket, uint32, uint32, error) { + header, packetID, unixTime, plain, err := crypt.Unwrap(packet) + if err != nil { + return nil, 0, 0, err + } + if len(header) != TLSCryptHeaderSize { + return nil, 0, 0, fmt.Errorf("invalid control header length %d", len(header)) + } + opcode, keyID := parseOpcodeKeyID(header[0]) + if !opcode.IsControl() { + return nil, 0, 0, fmt.Errorf("opcode %s is not a control opcode", opcode) + } + var local SessionID + copy(local[:], header[1:]) + + ackIDs, ackRemote, messageID, payload, err := DecodeControlPlain(opcode, plain) + if err != nil { + return nil, 0, 0, err + } + return &ControlPacket{ + Opcode: opcode, + KeyID: keyID, + LocalSession: local, + AckIDs: ackIDs, + AckRemoteSession: ackRemote, + MessageID: messageID, + Payload: payload, + }, packetID, unixTime, nil +} + +func DecodeControlPlain(opcode Opcode, plain []byte) (ackIDs []uint32, ackRemote SessionID, messageID uint32, payload []byte, err error) { + if len(plain) < 1 { + return nil, SessionID{}, 0, nil, errors.New("control payload too short") + } + ackLen := int(plain[0]) + offset := 1 + if len(plain) < offset+ackLen*4 { + return nil, SessionID{}, 0, nil, errors.New("control ack array truncated") + } + ackIDs = make([]uint32, ackLen) + for i := 0; i < ackLen; i++ { + ackIDs[i] = binary.BigEndian.Uint32(plain[offset : offset+4]) + offset += 4 + } + if ackLen > 0 { + if len(plain) < offset+SessionIDSize { + return nil, SessionID{}, 0, nil, errors.New("control ack remote session truncated") + } + copy(ackRemote[:], plain[offset:offset+SessionIDSize]) + offset += SessionIDSize + } + if opcode.HasMessageID() { + if len(plain) < offset+4 { + return nil, SessionID{}, 0, nil, errors.New("control message id truncated") + } + messageID = binary.BigEndian.Uint32(plain[offset : offset+4]) + offset += 4 + payload = cloneBytes(plain[offset:]) + } else if len(plain) != offset { + return nil, SessionID{}, 0, nil, errors.New("ack packet has trailing payload") + } + return ackIDs, ackRemote, messageID, payload, nil +} diff --git a/transport/openvpn/push.go b/transport/openvpn/push.go new file mode 100644 index 00000000..3fe9af1f --- /dev/null +++ b/transport/openvpn/push.go @@ -0,0 +1,139 @@ +package openvpn + +import ( + "fmt" + "net/netip" + "strconv" + "strings" +) + +const PushRequest = "PUSH_REQUEST" + +type PushReply struct { + Raw string + Prefixes []netip.Prefix + DNS []netip.Addr + PeerID uint32 + Cipher string + Ping uint32 + MTU uint32 + CompLZO bool + Redirect bool + BlockIPv6 bool +} + +func ParsePushReply(message string) (*PushReply, error) { + message = strings.TrimRight(message, "\x00") + if !strings.HasPrefix(message, "PUSH_REPLY") { + return nil, fmt.Errorf("unexpected openvpn push message %q", message) + } + reply := &PushReply{ + Raw: message, + PeerID: PeerIDUnset, + } + for _, option := range splitPushOptions(message) { + fields := strings.Fields(option) + if len(fields) == 0 { + continue + } + switch fields[0] { + case "ifconfig": + if len(fields) >= 3 { + prefix, err := parseIPv4Ifconfig(fields[1], fields[2]) + if err != nil { + return nil, err + } + reply.Prefixes = append(reply.Prefixes, prefix) + } + case "ifconfig-ipv6": + if len(fields) >= 2 { + prefix, err := netip.ParsePrefix(fields[1]) + if err != nil { + return nil, fmt.Errorf("parse pushed ipv6 address %q: %w", fields[1], err) + } + reply.Prefixes = append(reply.Prefixes, prefix) + } + case "dhcp-option": + if len(fields) >= 3 && fields[1] == "DNS" { + if addr, err := netip.ParseAddr(fields[2]); err == nil { + reply.DNS = append(reply.DNS, addr) + } + } + case "peer-id": + if len(fields) >= 2 { + id, err := strconv.ParseUint(fields[1], 10, 24) + if err != nil { + return nil, fmt.Errorf("parse pushed peer-id %q: %w", fields[1], err) + } + reply.PeerID = uint32(id) + } + case "redirect-gateway": + reply.Redirect = true + case "block-ipv6": + reply.BlockIPv6 = true + case "cipher": + if len(fields) >= 2 { + reply.Cipher = fields[1] + } + case "ping": + if len(fields) >= 2 { + if v, err := strconv.ParseUint(fields[1], 10, 32); err == nil { + reply.Ping = uint32(v) + } + } + case "tun-mtu": + if len(fields) >= 2 { + if v, err := strconv.ParseUint(fields[1], 10, 32); err == nil { + reply.MTU = uint32(v) + } + } + case "comp-lzo": + reply.CompLZO = true + } + } + if len(reply.Prefixes) == 0 { + return nil, fmt.Errorf("openvpn push reply missing ifconfig address") + } + return reply, nil +} + +func splitPushOptions(message string) []string { + message = strings.TrimRight(message, "\x00") + parts := strings.Split(message, ",") + if len(parts) > 0 && parts[0] == "PUSH_REPLY" { + parts = parts[1:] + } + out := parts[:0] + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +func parseIPv4Ifconfig(address, mask string) (netip.Prefix, error) { + addr, err := netip.ParseAddr(address) + if err != nil { + return netip.Prefix{}, fmt.Errorf("parse pushed ipv4 address %q: %w", address, err) + } + maskAddr, err := netip.ParseAddr(mask) + if err != nil { + return netip.Prefix{}, fmt.Errorf("parse pushed ipv4 mask %q: %w", mask, err) + } + if !addr.Is4() || !maskAddr.Is4() { + return netip.Prefix{}, fmt.Errorf("openvpn ifconfig requires ipv4 address and mask") + } + maskBytes := maskAddr.As4() + ones := 0 + for _, b := range maskBytes { + for i := 7; i >= 0; i-- { + if b&(1< 0 { + t.mtu = client.push.MTU + } + deviceOptions := DeviceOptions{ + Context: t.ctx, + Logger: t.logger, + UDPTimeout: t.options.UDPTimeout, + MTU: t.mtu, + Address: client.push.Prefixes, + } + device, err := NewDevice(deviceOptions) + if err != nil { + client.Close() + t.logger.Error("create OpenVPN device: ", err) + return + } + t.device = device + if err := device.Start(); err != nil { + client.Close() + t.logger.Error("start OpenVPN device: ", err) + return + } + t.maintainTunnel() + }() + return nil +} + +func (t *Tunnel) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if !destination.Addr.IsValid() { + return nil, E.Cause(os.ErrInvalid, "invalid non-IP destination") + } + return t.device.DialContext(ctx, network, destination) +} + +func (t *Tunnel) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if !destination.Addr.IsValid() { + return nil, E.Cause(os.ErrInvalid, "invalid non-IP destination") + } + return t.device.ListenPacket(ctx, destination) +} + +func (t *Tunnel) Close() error { + t.mu.Lock() + defer t.mu.Unlock() + if t.client != nil { + t.client.Close() + t.client = nil + } + if t.device != nil { + return t.device.Close() + } + return nil +} + +func (t *Tunnel) isTunnelInitialized(ctx context.Context) error { + select { + case <-t.await: + case <-ctx.Done(): + return ctx.Err() + } + if t.device == nil { + return E.New("endpoint not initialized") + } + return nil +} + +func (t *Tunnel) maintainTunnel() { + go func() { + bufs := make([][]byte, 1) + bufs[0] = make([]byte, t.mtu) + sizes := make([]int, 1) + for t.ctx.Err() == nil { + _, err := t.device.Read(bufs, sizes, 0) + if err != nil { + if t.ctx.Err() != nil { + return + } + continue + } + client, err := t.getClient() + if err != nil { + return + } + if err := client.WriteIPPacket(t.ctx, bufs[0][:sizes[0]]); err != nil { + if t.ctx.Err() != nil { + return + } + } + } + }() + go func() { + for t.ctx.Err() == nil { + client, err := t.getClient() + if err != nil { + return + } + packet, err := client.ReadIPPacket(t.ctx) + if err != nil { + if t.ctx.Err() != nil { + return + } + if ok := t.closeClient(client); ok { + t.logger.ErrorContext(t.ctx, fmt.Errorf("connection lost: %v", err)) + } + continue + } + if bytes.Equal(packet, pingPayload) { + continue + } + if _, err := t.device.Write([][]byte{packet}, 0); err != nil { + if t.ctx.Err() != nil { + return + } + } + } + }() + pingInterval := t.options.PingInterval + if pingInterval == 0 && t.client != nil && t.client.push.Ping > 0 { + pingInterval = time.Duration(t.client.push.Ping) * time.Second + } + if pingInterval > 0 { + go func() { + ticker := time.NewTicker(pingInterval) + defer ticker.Stop() + for { + select { + case <-t.ctx.Done(): + return + case <-ticker.C: + client, err := t.getClient() + if err != nil { + return + } + client.WriteIPPacket(t.ctx, pingPayload) + } + } + }() + } + <-t.ctx.Done() +} + +func (t *Tunnel) getClient() (*Client, error) { + t.mu.Lock() + defer t.mu.Unlock() + if t.ctx.Err() != nil { + return nil, t.ctx.Err() + } + if t.client != nil { + return t.client, nil + } + timer := time.NewTimer(0) + defer timer.Stop() + for { + t.logger.InfoContext(t.ctx, "connecting to OpenVPN server") + client, err := t.connect() + if err != nil { + t.logger.ErrorContext(t.ctx, fmt.Errorf("connect failed: %v", err)) + timer.Reset(t.options.ReconnectDelay) + select { + case <-t.ctx.Done(): + return nil, t.ctx.Err() + case <-timer.C: + } + continue + } + t.client = client + t.logger.InfoContext(t.ctx, "connected to OpenVPN server") + return client, nil + } +} + +func (t *Tunnel) closeClient(client *Client) bool { + t.mu.Lock() + defer t.mu.Unlock() + if client == t.client { + t.client.Close() + t.client = nil + return true + } + return false +} + +func (t *Tunnel) connect() (*Client, error) { + config := t.options.Config + server := t.options.Servers[t.serverIndex].Build() + t.serverIndex = (t.serverIndex + 1) % len(t.options.Servers) + connectCtx, cancel := context.WithTimeout(t.ctx, t.options.ReconnectDelay) + defer cancel() + var conn net.Conn + var err error + if config.Proto == ProtoTCP { + conn, err = t.options.Dialer.DialContext(connectCtx, N.NetworkTCP, server) + } else { + conn, err = t.options.Dialer.DialContext(connectCtx, N.NetworkUDP, server) + } + if err != nil { + return nil, fmt.Errorf("dial openvpn server: %w", err) + } + var packetIO PacketIO + if config.Proto == ProtoTCP { + packetIO = NewTCPPacketIO(conn) + } else { + packetIO = NewDatagramPacketIO(conn) + } + client, err := NewClient(config, packetIO, t.options.TLSConfig) + if err != nil { + conn.Close() + return nil, err + } + _, err = client.Handshake(connectCtx) + if err != nil { + client.Close() + return nil, fmt.Errorf("openvpn handshake: %w", err) + } + return client, nil +} + +var pingPayload = []byte{ + 0x2a, 0x18, 0x7b, 0xf3, 0x64, 0x1e, 0xb4, 0xcb, + 0x07, 0xed, 0x2d, 0x0a, 0x98, 0x1f, 0xc7, 0x48, +} diff --git a/transport/sudoku/address.go b/transport/sudoku/address.go new file mode 100644 index 00000000..d68205ad --- /dev/null +++ b/transport/sudoku/address.go @@ -0,0 +1,100 @@ +package sudoku + +import ( + "encoding/binary" + "fmt" + "io" + "net" + "strconv" + "strings" +) + +func EncodeAddress(rawAddr string) ([]byte, error) { + host, portStr, err := net.SplitHostPort(rawAddr) + if err != nil { + return nil, err + } + + portInt, err := strconv.ParseUint(portStr, 10, 16) + if err != nil { + return nil, err + } + + var buf []byte + if i := strings.IndexByte(host, '%'); i >= 0 { + // Zone identifiers are not representable in SOCKS5 IPv6 address encoding. + host = host[:i] + } + if ip := net.ParseIP(host); ip != nil { + if ip4 := ip.To4(); ip4 != nil { + buf = append(buf, 0x01) // IPv4 + buf = append(buf, ip4...) + } else { + buf = append(buf, 0x04) // IPv6 + ip16 := ip.To16() + if ip16 == nil { + return nil, fmt.Errorf("invalid ipv6: %q", host) + } + buf = append(buf, ip16...) + } + } else { + if len(host) > 255 { + return nil, fmt.Errorf("domain too long") + } + buf = append(buf, 0x03) // domain + buf = append(buf, byte(len(host))) + buf = append(buf, host...) + } + + var portBytes [2]byte + binary.BigEndian.PutUint16(portBytes[:], uint16(portInt)) + buf = append(buf, portBytes[:]...) + return buf, nil +} + +func DecodeAddress(r io.Reader) (string, error) { + var atyp [1]byte + if _, err := io.ReadFull(r, atyp[:]); err != nil { + return "", err + } + + switch atyp[0] { + case 0x01: // IPv4 + var ipBuf [net.IPv4len]byte + if _, err := io.ReadFull(r, ipBuf[:]); err != nil { + return "", err + } + var portBuf [2]byte + if _, err := io.ReadFull(r, portBuf[:]); err != nil { + return "", err + } + return net.JoinHostPort(net.IP(ipBuf[:]).String(), fmt.Sprint(binary.BigEndian.Uint16(portBuf[:]))), nil + case 0x04: // IPv6 + var ipBuf [net.IPv6len]byte + if _, err := io.ReadFull(r, ipBuf[:]); err != nil { + return "", err + } + var portBuf [2]byte + if _, err := io.ReadFull(r, portBuf[:]); err != nil { + return "", err + } + return net.JoinHostPort(net.IP(ipBuf[:]).String(), fmt.Sprint(binary.BigEndian.Uint16(portBuf[:]))), nil + case 0x03: // domain + var lengthBuf [1]byte + if _, err := io.ReadFull(r, lengthBuf[:]); err != nil { + return "", err + } + l := int(lengthBuf[0]) + hostBuf := make([]byte, l) + if _, err := io.ReadFull(r, hostBuf); err != nil { + return "", err + } + var portBuf [2]byte + if _, err := io.ReadFull(r, portBuf[:]); err != nil { + return "", err + } + return net.JoinHostPort(string(hostBuf), fmt.Sprint(binary.BigEndian.Uint16(portBuf[:]))), nil + default: + return "", fmt.Errorf("unknown address type: %d", atyp[0]) + } +} diff --git a/transport/sudoku/config.go b/transport/sudoku/config.go new file mode 100644 index 00000000..cedcf184 --- /dev/null +++ b/transport/sudoku/config.go @@ -0,0 +1,212 @@ +package sudoku + +import ( + "fmt" + "strings" + + "github.com/sagernet/sing-box/transport/sudoku/obfs/sudoku" +) + +// ProtocolConfig defines the configuration for the Sudoku protocol stack. +// It is intentionally kept close to the upstream Sudoku project to ensure wire compatibility. +type ProtocolConfig struct { + // Client-only: "host:port". + ServerAddress string + + // Pre-shared key (or ED25519 key material) used to derive crypto and tables. + Key string + + // "aes-128-gcm", "chacha20-poly1305", or "none". + AEADMethod string + + // Table is the single obfuscation table to use when table rotation is disabled. + Table *sudoku.Table + + // Tables is an optional candidate set for table rotation. + // If provided (len>0), the client will pick one table per connection and the server will + // probe the handshake to detect which one was used, keeping the handshake format unchanged. + // When Tables is set, Table may be nil. + Tables []*sudoku.Table + + // Padding insertion ratio (0-100). Must satisfy PaddingMax >= PaddingMin. + PaddingMin int + PaddingMax int + + // EnablePureDownlink enables the pure Sudoku downlink mode. + // When false, the connection uses the bandwidth-optimized packed downlink. + EnablePureDownlink bool + + // Client-only: final target "host:port". + TargetAddress string + + // Server-side handshake timeout (seconds). + HandshakeTimeoutSeconds int + + // DisableHTTPMask disables all HTTP camouflage layers. + DisableHTTPMask bool + + // HTTPMaskMode controls how the HTTP layer behaves: + // - "legacy": write a fake HTTP/1.1 header then switch to raw stream (default, not CDN-compatible) + // - "stream": real HTTP tunnel (split-stream), CDN-compatible + // - "poll": plain HTTP tunnel (authorize/push/pull), strong restricted-network pass-through + // - "auto": try stream then fall back to poll + // - "ws": WebSocket tunnel (GET upgrade), CDN-friendly + HTTPMaskMode string + + // HTTPMaskTLSEnabled enables HTTPS for HTTP tunnel modes (client-side). + // If false, the tunnel uses HTTP (no port-based inference). + HTTPMaskTLSEnabled bool + + // HTTPMaskHost optionally overrides the HTTP Host header / SNI host for HTTP tunnel modes (client-side). + HTTPMaskHost string + + // HTTPMaskPathRoot optionally prefixes all HTTP mask paths with a first-level segment. + // Example: "aabbcc" => "/aabbcc/session", "/aabbcc/api/v1/upload", ... + HTTPMaskPathRoot string + + // HTTPMaskMultiplex controls multiplex behavior when HTTPMask tunnel modes are enabled: + // - "off": disable reuse; each Dial establishes its own HTTPMask tunnel + // - "auto": reuse underlying HTTP connections across multiple tunnel dials (HTTP/1.1 keep-alive / HTTP/2) + // - "on": enable "single tunnel, multi-target" mux (Sudoku-level multiplex; Dial behaves like "auto" otherwise) + HTTPMaskMultiplex string +} + +func (c *ProtocolConfig) Validate() error { + if c.Table == nil && len(c.Tables) == 0 { + return fmt.Errorf("table cannot be nil (or provide tables)") + } + for i, t := range c.Tables { + if t == nil { + return fmt.Errorf("tables[%d] cannot be nil", i) + } + } + + if c.Key == "" { + return fmt.Errorf("key cannot be empty") + } + + switch c.AEADMethod { + case "aes-128-gcm", "chacha20-poly1305", "none": + default: + return fmt.Errorf("invalid aead-method: %s, must be one of: aes-128-gcm, chacha20-poly1305, none", c.AEADMethod) + } + + if c.PaddingMin < 0 || c.PaddingMin > 100 { + return fmt.Errorf("padding-min must be between 0 and 100, got %d", c.PaddingMin) + } + if c.PaddingMax < 0 || c.PaddingMax > 100 { + return fmt.Errorf("padding-max must be between 0 and 100, got %d", c.PaddingMax) + } + if c.PaddingMax < c.PaddingMin { + return fmt.Errorf("padding-max (%d) must be >= padding-min (%d)", c.PaddingMax, c.PaddingMin) + } + + if c.HandshakeTimeoutSeconds < 0 { + return fmt.Errorf("handshake-timeout must be >= 0, got %d", c.HandshakeTimeoutSeconds) + } + + switch strings.ToLower(strings.TrimSpace(c.HTTPMaskMode)) { + case "", "legacy", "stream", "poll", "auto", "ws": + default: + return fmt.Errorf("invalid http-mask-mode: %s, must be one of: legacy, stream, poll, auto, ws", c.HTTPMaskMode) + } + + if v := strings.TrimSpace(c.HTTPMaskPathRoot); v != "" { + v = strings.Trim(v, "/") + if v == "" || strings.Contains(v, "/") { + return fmt.Errorf("invalid http-mask-path-root: must be a single path segment") + } + for i := 0; i < len(v); i++ { + ch := v[i] + switch { + case ch >= 'a' && ch <= 'z': + case ch >= 'A' && ch <= 'Z': + case ch >= '0' && ch <= '9': + case ch == '_' || ch == '-': + default: + return fmt.Errorf("invalid http-mask-path-root: contains invalid character %q", ch) + } + } + } + + switch strings.ToLower(strings.TrimSpace(c.HTTPMaskMultiplex)) { + case "", "off", "auto", "on": + default: + return fmt.Errorf("invalid http-mask-multiplex: %s, must be one of: off, auto, on", c.HTTPMaskMultiplex) + } + + return nil +} + +func (c *ProtocolConfig) ValidateClient() error { + if err := c.Validate(); err != nil { + return err + } + if c.ServerAddress == "" { + return fmt.Errorf("server address cannot be empty") + } + if c.TargetAddress == "" { + return fmt.Errorf("target address cannot be empty") + } + return nil +} + +func DefaultConfig() *ProtocolConfig { + return &ProtocolConfig{ + AEADMethod: "chacha20-poly1305", + PaddingMin: 10, + PaddingMax: 30, + EnablePureDownlink: true, + HandshakeTimeoutSeconds: 5, + HTTPMaskMode: "legacy", + HTTPMaskMultiplex: "off", + } +} + +func DerefInt(v *int, def int) int { + if v == nil { + return def + } + return *v +} + +func DerefBool(v *bool, def bool) bool { + if v == nil { + return def + } + return *v +} + +// ResolvePadding applies defaults and keeps min/max consistent when only one side is provided. +func ResolvePadding(min, max *int, defMin, defMax int) (int, int) { + paddingMin := DerefInt(min, defMin) + paddingMax := DerefInt(max, defMax) + switch { + case min == nil && max != nil && paddingMax < paddingMin: + paddingMin = paddingMax + case max == nil && min != nil && paddingMax < paddingMin: + paddingMax = paddingMin + } + return paddingMin, paddingMax +} + +func NormalizeTableType(tableType string) (string, error) { + normalized, err := sudoku.NormalizeASCIIMode(tableType) + if err != nil { + return "", fmt.Errorf("table-type must be prefer_ascii, prefer_entropy, up_ascii_down_entropy, or up_entropy_down_ascii") + } + return normalized, nil +} + +func (c *ProtocolConfig) tableCandidates() []*sudoku.Table { + if c == nil { + return nil + } + if len(c.Tables) > 0 { + return c.Tables + } + if c.Table != nil { + return []*sudoku.Table{c.Table} + } + return nil +} diff --git a/transport/sudoku/crypto/aead.go b/transport/sudoku/crypto/aead.go new file mode 100644 index 00000000..368caea3 --- /dev/null +++ b/transport/sudoku/crypto/aead.go @@ -0,0 +1,150 @@ +package crypto + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + + "golang.org/x/crypto/chacha20poly1305" +) + +type AEADConn struct { + net.Conn + aead cipher.AEAD + readBuf bytes.Buffer + nonceSize int +} + +func (cc *AEADConn) CloseWrite() error { + if cc == nil || cc.Conn == nil { + return nil + } + if cw, ok := cc.Conn.(interface{ CloseWrite() error }); ok { + return cw.CloseWrite() + } + return nil +} + +func (cc *AEADConn) CloseRead() error { + if cc == nil || cc.Conn == nil { + return nil + } + if cr, ok := cc.Conn.(interface{ CloseRead() error }); ok { + return cr.CloseRead() + } + return nil +} + +func NewAEADConn(c net.Conn, key string, method string) (*AEADConn, error) { + if method == "none" { + return &AEADConn{Conn: c, aead: nil}, nil + } + + h := sha256.New() + h.Write([]byte(key)) + keyBytes := h.Sum(nil) + + var aead cipher.AEAD + var err error + + switch method { + case "aes-128-gcm": + block, _ := aes.NewCipher(keyBytes[:16]) + aead, err = cipher.NewGCM(block) + case "chacha20-poly1305": + aead, err = chacha20poly1305.New(keyBytes) + default: + return nil, fmt.Errorf("unsupported cipher: %s", method) + } + if err != nil { + return nil, err + } + + return &AEADConn{ + Conn: c, + aead: aead, + nonceSize: aead.NonceSize(), + }, nil +} + +func (cc *AEADConn) Write(p []byte) (int, error) { + if cc.aead == nil { + return cc.Conn.Write(p) + } + + maxPayload := 65535 - cc.nonceSize - cc.aead.Overhead() + totalWritten := 0 + var frameBuf bytes.Buffer + header := make([]byte, 2) + nonce := make([]byte, cc.nonceSize) + + for len(p) > 0 { + chunkSize := len(p) + if chunkSize > maxPayload { + chunkSize = maxPayload + } + chunk := p[:chunkSize] + p = p[chunkSize:] + + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return totalWritten, err + } + + ciphertext := cc.aead.Seal(nil, nonce, chunk, nil) + frameLen := len(nonce) + len(ciphertext) + binary.BigEndian.PutUint16(header, uint16(frameLen)) + + frameBuf.Reset() + frameBuf.Write(header) + frameBuf.Write(nonce) + frameBuf.Write(ciphertext) + + if _, err := cc.Conn.Write(frameBuf.Bytes()); err != nil { + return totalWritten, err + } + totalWritten += chunkSize + } + return totalWritten, nil +} + +func (cc *AEADConn) Read(p []byte) (int, error) { + if cc.aead == nil { + return cc.Conn.Read(p) + } + + if cc.readBuf.Len() > 0 { + return cc.readBuf.Read(p) + } + + header := make([]byte, 2) + if _, err := io.ReadFull(cc.Conn, header); err != nil { + return 0, err + } + frameLen := int(binary.BigEndian.Uint16(header)) + + body := make([]byte, frameLen) + if _, err := io.ReadFull(cc.Conn, body); err != nil { + return 0, err + } + + if len(body) < cc.nonceSize { + return 0, errors.New("frame too short") + } + nonce := body[:cc.nonceSize] + ciphertext := body[cc.nonceSize:] + + plaintext, err := cc.aead.Open(nil, nonce, ciphertext, nil) + if err != nil { + return 0, errors.New("decryption failed") + } + + cc.readBuf.Write(plaintext) + return cc.readBuf.Read(p) +} diff --git a/transport/sudoku/crypto/ed25519.go b/transport/sudoku/crypto/ed25519.go new file mode 100644 index 00000000..fa500b13 --- /dev/null +++ b/transport/sudoku/crypto/ed25519.go @@ -0,0 +1,116 @@ +package crypto + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + + "filippo.io/edwards25519" +) + +// KeyPair holds the scalar private key and point public key +type KeyPair struct { + Private *edwards25519.Scalar + Public *edwards25519.Point +} + +// GenerateMasterKey generates a random master private key (scalar) and its public key (point) +func GenerateMasterKey() (*KeyPair, error) { + // 1. Generate random scalar x (32 bytes) + var seed [64]byte + if _, err := rand.Read(seed[:]); err != nil { + return nil, err + } + + x, err := edwards25519.NewScalar().SetUniformBytes(seed[:]) + if err != nil { + return nil, err + } + + // 2. Calculate Public Key P = x * G + P := new(edwards25519.Point).ScalarBaseMult(x) + + return &KeyPair{Private: x, Public: P}, nil +} + +// SplitPrivateKey takes a master private key x and returns a new random split key (r, k) +// such that x = r + k (mod L). +// Returns hex encoded string of r || k (64 bytes) +func SplitPrivateKey(x *edwards25519.Scalar) (string, error) { + // 1. Generate random r (32 bytes) + var seed [64]byte + if _, err := rand.Read(seed[:]); err != nil { + return "", err + } + r, err := edwards25519.NewScalar().SetUniformBytes(seed[:]) + if err != nil { + return "", err + } + + // 2. Calculate k = x - r (mod L) + k := new(edwards25519.Scalar).Subtract(x, r) + + // 3. Encode r and k + rBytes := r.Bytes() + kBytes := k.Bytes() + + full := make([]byte, 64) + copy(full[:32], rBytes) + copy(full[32:], kBytes) + + return hex.EncodeToString(full), nil +} + +// RecoverPublicKey takes a split private key (r, k) or a master private key (x) +// and returns the public key P. +// Input can be: +// - 32 bytes hex (Master Scalar x) +// - 64 bytes hex (Split Key r || k) +func RecoverPublicKey(keyHex string) (*edwards25519.Point, error) { + keyBytes, err := hex.DecodeString(keyHex) + if err != nil { + return nil, fmt.Errorf("invalid hex: %w", err) + } + + if len(keyBytes) == 32 { + // Master Key x + x, err := edwards25519.NewScalar().SetCanonicalBytes(keyBytes) + if err != nil { + return nil, fmt.Errorf("invalid scalar: %w", err) + } + return new(edwards25519.Point).ScalarBaseMult(x), nil + } + if len(keyBytes) == 64 { + // Split Key r || k + rBytes := keyBytes[:32] + kBytes := keyBytes[32:] + + r, err := edwards25519.NewScalar().SetCanonicalBytes(rBytes) + if err != nil { + return nil, fmt.Errorf("invalid scalar r: %w", err) + } + k, err := edwards25519.NewScalar().SetCanonicalBytes(kBytes) + if err != nil { + return nil, fmt.Errorf("invalid scalar k: %w", err) + } + + // sum = r + k + sum := new(edwards25519.Scalar).Add(r, k) + + // P = sum * G + return new(edwards25519.Point).ScalarBaseMult(sum), nil + } + + return nil, errors.New("invalid key length: must be 32 bytes (Master) or 64 bytes (Split)") +} + +// EncodePoint returns the hex string of the compressed point +func EncodePoint(p *edwards25519.Point) string { + return hex.EncodeToString(p.Bytes()) +} + +// EncodeScalar returns the hex string of the scalar +func EncodeScalar(s *edwards25519.Scalar) string { + return hex.EncodeToString(s.Bytes()) +} diff --git a/transport/sudoku/crypto/record_conn.go b/transport/sudoku/crypto/record_conn.go new file mode 100644 index 00000000..7c035715 --- /dev/null +++ b/transport/sudoku/crypto/record_conn.go @@ -0,0 +1,452 @@ +package crypto + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "sync" + "sync/atomic" + + "golang.org/x/crypto/chacha20poly1305" +) + +// KeyUpdateAfterBytes controls automatic key rotation based on plaintext bytes. +// It is a package var (not config) to enable targeted tests with smaller thresholds. +var KeyUpdateAfterBytes int64 = 32 << 20 // 32 MiB + +const ( + recordHeaderSize = 12 // epoch(uint32) + seq(uint64) - also used as nonce+AAD. + maxFrameBodySize = 65535 +) + +type recordKeys struct { + baseSend []byte + baseRecv []byte +} + +// RecordConn is a framed AEAD net.Conn with: +// - deterministic per-record nonce (epoch+seq) +// - per-direction key rotation (epoch), driven by plaintext byte counters +// - replay/out-of-order protection within the connection (strict seq check) +// +// Wire format per record: +// - uint16 bodyLen +// - header[12] = epoch(uint32 BE) || seq(uint64 BE) (plaintext) +// - ciphertext = AEAD(header as nonce, plaintext, header as AAD) +type RecordConn struct { + net.Conn + method string + + writeMu sync.Mutex + readMu sync.Mutex + + keys recordKeys + + sendAEAD cipher.AEAD + sendAEADEpoch uint32 + + recvAEAD cipher.AEAD + recvAEADEpoch uint32 + + // Send direction state. + sendEpoch uint32 + sendSeq uint64 + sendBytes int64 + sendEpochUpdates uint32 + + // Receive direction state. + recvEpoch uint32 + recvSeq uint64 + recvInitialized bool + + readBuf bytes.Buffer + + // writeFrame is a reusable buffer for [len||header||ciphertext] on the wire. + // Guarded by writeMu. + writeFrame []byte +} + +func (c *RecordConn) CloseWrite() error { + if c == nil { + return nil + } + if cw, ok := c.Conn.(interface{ CloseWrite() error }); ok { + return cw.CloseWrite() + } + return nil +} + +func (c *RecordConn) CloseRead() error { + if c == nil { + return nil + } + if cr, ok := c.Conn.(interface{ CloseRead() error }); ok { + return cr.CloseRead() + } + return nil +} + +func NewRecordConn(conn net.Conn, method string, baseSend, baseRecv []byte) (*RecordConn, error) { + if conn == nil { + return nil, fmt.Errorf("nil conn") + } + method = normalizeAEADMethod(method) + if method != "none" { + if err := validateBaseKey(baseSend); err != nil { + return nil, fmt.Errorf("invalid send base key: %w", err) + } + if err := validateBaseKey(baseRecv); err != nil { + return nil, fmt.Errorf("invalid recv base key: %w", err) + } + } + rc := &RecordConn{Conn: conn, method: method} + rc.keys = recordKeys{baseSend: cloneBytes(baseSend), baseRecv: cloneBytes(baseRecv)} + if err := rc.resetTrafficState(); err != nil { + return nil, err + } + return rc, nil +} + +func (c *RecordConn) Rekey(baseSend, baseRecv []byte) error { + if c == nil { + return fmt.Errorf("nil conn") + } + if c.method != "none" { + if err := validateBaseKey(baseSend); err != nil { + return fmt.Errorf("invalid send base key: %w", err) + } + if err := validateBaseKey(baseRecv); err != nil { + return fmt.Errorf("invalid recv base key: %w", err) + } + } + + c.writeMu.Lock() + c.readMu.Lock() + defer c.readMu.Unlock() + defer c.writeMu.Unlock() + + c.keys = recordKeys{baseSend: cloneBytes(baseSend), baseRecv: cloneBytes(baseRecv)} + if err := c.resetTrafficState(); err != nil { + return err + } + c.readBuf.Reset() + + c.sendAEAD = nil + c.recvAEAD = nil + c.sendAEADEpoch = 0 + c.recvAEADEpoch = 0 + return nil +} + +func (c *RecordConn) resetTrafficState() error { + sendEpoch, sendSeq, err := randomRecordCounters() + if err != nil { + return fmt.Errorf("initialize record counters: %w", err) + } + c.sendEpoch = sendEpoch + c.sendSeq = sendSeq + c.sendBytes = 0 + c.sendEpochUpdates = 0 + c.recvEpoch = 0 + c.recvSeq = 0 + c.recvInitialized = false + return nil +} + +func normalizeAEADMethod(method string) string { + switch method { + case "", "chacha20-poly1305": + return "chacha20-poly1305" + case "aes-128-gcm", "none": + return method + default: + return method + } +} + +func validateBaseKey(b []byte) error { + if len(b) < 32 { + return fmt.Errorf("need at least 32 bytes, got %d", len(b)) + } + return nil +} + +func cloneBytes(b []byte) []byte { + if len(b) == 0 { + return nil + } + return append([]byte(nil), b...) +} + +func randomRecordCounters() (uint32, uint64, error) { + epoch, err := randomNonZeroUint32() + if err != nil { + return 0, 0, err + } + seq, err := randomNonZeroUint64() + if err != nil { + return 0, 0, err + } + return epoch, seq, nil +} + +func randomNonZeroUint32() (uint32, error) { + var b [4]byte + for { + if _, err := io.ReadFull(rand.Reader, b[:]); err != nil { + return 0, err + } + v := binary.BigEndian.Uint32(b[:]) + if v != 0 && v != ^uint32(0) { + return v, nil + } + } +} + +func randomNonZeroUint64() (uint64, error) { + var b [8]byte + for { + if _, err := io.ReadFull(rand.Reader, b[:]); err != nil { + return 0, err + } + v := binary.BigEndian.Uint64(b[:]) + if v != 0 && v != ^uint64(0) { + return v, nil + } + } +} + +func (c *RecordConn) newAEADFor(base []byte, epoch uint32) (cipher.AEAD, error) { + if c.method == "none" { + return nil, nil + } + key := deriveEpochKey(base, epoch, c.method) + switch c.method { + case "aes-128-gcm": + block, err := aes.NewCipher(key[:16]) + if err != nil { + return nil, err + } + a, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + if a.NonceSize() != recordHeaderSize { + return nil, fmt.Errorf("unexpected gcm nonce size: %d", a.NonceSize()) + } + return a, nil + case "chacha20-poly1305": + a, err := chacha20poly1305.New(key[:32]) + if err != nil { + return nil, err + } + if a.NonceSize() != recordHeaderSize { + return nil, fmt.Errorf("unexpected chacha nonce size: %d", a.NonceSize()) + } + return a, nil + default: + return nil, fmt.Errorf("unsupported cipher: %s", c.method) + } +} + +func deriveEpochKey(base []byte, epoch uint32, method string) []byte { + var b [4]byte + binary.BigEndian.PutUint32(b[:], epoch) + mac := hmac.New(sha256.New, base) + _, _ = mac.Write([]byte("sudoku-record:")) + _, _ = mac.Write([]byte(method)) + _, _ = mac.Write(b[:]) + return mac.Sum(nil) +} + +func (c *RecordConn) maybeBumpSendEpochLocked(addedPlain int) error { + ku := atomic.LoadInt64(&KeyUpdateAfterBytes) + if ku <= 0 || c.method == "none" { + return nil + } + c.sendBytes += int64(addedPlain) + threshold := ku * int64(c.sendEpochUpdates+1) + if c.sendBytes < threshold { + return nil + } + c.sendEpoch++ + c.sendEpochUpdates++ + nextSeq, err := randomNonZeroUint64() + if err != nil { + return fmt.Errorf("rotate record seq: %w", err) + } + c.sendSeq = nextSeq + return nil +} + +func (c *RecordConn) validateRecvPosition(epoch uint32, seq uint64) error { + if !c.recvInitialized { + return nil + } + if epoch < c.recvEpoch { + return fmt.Errorf("replayed epoch: got %d want >=%d", epoch, c.recvEpoch) + } + if epoch == c.recvEpoch && seq != c.recvSeq { + return fmt.Errorf("out of order: epoch=%d got=%d want=%d", epoch, seq, c.recvSeq) + } + if epoch > c.recvEpoch { + const maxJump = 8 + if epoch-c.recvEpoch > maxJump { + return fmt.Errorf("epoch jump too large: got=%d want<=%d", epoch-c.recvEpoch, maxJump) + } + } + return nil +} + +func (c *RecordConn) markRecvPosition(epoch uint32, seq uint64) { + c.recvEpoch = epoch + c.recvSeq = seq + 1 + c.recvInitialized = true +} + +func (c *RecordConn) Write(p []byte) (int, error) { + if c == nil || c.Conn == nil { + return 0, net.ErrClosed + } + if c.method == "none" { + return c.Conn.Write(p) + } + + c.writeMu.Lock() + defer c.writeMu.Unlock() + + total := 0 + for len(p) > 0 { + if c.sendAEAD == nil || c.sendAEADEpoch != c.sendEpoch { + a, err := c.newAEADFor(c.keys.baseSend, c.sendEpoch) + if err != nil { + return total, err + } + c.sendAEAD = a + c.sendAEADEpoch = c.sendEpoch + } + aead := c.sendAEAD + + maxPlain := maxFrameBodySize - recordHeaderSize - aead.Overhead() + if maxPlain <= 0 { + return total, errors.New("frame size too small") + } + n := len(p) + if n > maxPlain { + n = maxPlain + } + chunk := p[:n] + p = p[n:] + + var header [recordHeaderSize]byte + binary.BigEndian.PutUint32(header[:4], c.sendEpoch) + binary.BigEndian.PutUint64(header[4:], c.sendSeq) + c.sendSeq++ + + cipherLen := n + aead.Overhead() + bodyLen := recordHeaderSize + cipherLen + frameLen := 2 + bodyLen + if bodyLen > maxFrameBodySize { + return total, errors.New("frame too large") + } + if cap(c.writeFrame) < frameLen { + c.writeFrame = make([]byte, frameLen) + } + frame := c.writeFrame[:frameLen] + binary.BigEndian.PutUint16(frame[:2], uint16(bodyLen)) + copy(frame[2:2+recordHeaderSize], header[:]) + + dst := frame[2+recordHeaderSize : 2+recordHeaderSize : frameLen] + _ = aead.Seal(dst[:0], header[:], chunk, header[:]) + + if err := writeFull(c.Conn, frame); err != nil { + return total, err + } + + total += n + if err := c.maybeBumpSendEpochLocked(n); err != nil { + return total, err + } + } + return total, nil +} + +func (c *RecordConn) Read(p []byte) (int, error) { + if c == nil || c.Conn == nil { + return 0, net.ErrClosed + } + if c.method == "none" { + return c.Conn.Read(p) + } + + c.readMu.Lock() + defer c.readMu.Unlock() + + if c.readBuf.Len() > 0 { + return c.readBuf.Read(p) + } + + var lenBuf [2]byte + if _, err := io.ReadFull(c.Conn, lenBuf[:]); err != nil { + return 0, err + } + bodyLen := int(binary.BigEndian.Uint16(lenBuf[:])) + if bodyLen < recordHeaderSize { + return 0, errors.New("frame too short") + } + if bodyLen > maxFrameBodySize { + return 0, errors.New("frame too large") + } + + body := make([]byte, bodyLen) + if _, err := io.ReadFull(c.Conn, body); err != nil { + return 0, err + } + header := body[:recordHeaderSize] + ciphertext := body[recordHeaderSize:] + + epoch := binary.BigEndian.Uint32(header[:4]) + seq := binary.BigEndian.Uint64(header[4:]) + + if err := c.validateRecvPosition(epoch, seq); err != nil { + return 0, err + } + + if c.recvAEAD == nil || c.recvAEADEpoch != epoch { + a, err := c.newAEADFor(c.keys.baseRecv, epoch) + if err != nil { + return 0, err + } + c.recvAEAD = a + c.recvAEADEpoch = epoch + } + aead := c.recvAEAD + + plaintext, err := aead.Open(nil, header, ciphertext, header) + if err != nil { + return 0, fmt.Errorf("decryption failed: epoch=%d seq=%d: %w", epoch, seq, err) + } + c.markRecvPosition(epoch, seq) + + c.readBuf.Write(plaintext) + return c.readBuf.Read(p) +} + +func writeFull(w io.Writer, b []byte) error { + for len(b) > 0 { + n, err := w.Write(b) + if err != nil { + return err + } + b = b[n:] + } + return nil +} diff --git a/transport/sudoku/early_handshake.go b/transport/sudoku/early_handshake.go new file mode 100644 index 00000000..11a906e2 --- /dev/null +++ b/transport/sudoku/early_handshake.go @@ -0,0 +1,343 @@ +package sudoku + +import ( + "bytes" + "crypto/ecdh" + "crypto/rand" + "encoding/hex" + "fmt" + "net" + "time" + + "github.com/sagernet/sing-box/transport/sudoku/crypto" + httpmaskobfs "github.com/sagernet/sing-box/transport/sudoku/obfs/httpmask" + sudokuobfs "github.com/sagernet/sing-box/transport/sudoku/obfs/sudoku" +) + +const earlyKIPHandshakeTTL = 60 * time.Second + +type EarlyCodecConfig struct { + PSK string + AEAD string + EnablePureDownlink bool + PaddingMin int + PaddingMax int +} + +type EarlyClientState struct { + RequestPayload []byte + + cfg EarlyCodecConfig + table *sudokuobfs.Table + nonce [kipHelloNonceSize]byte + ephemeral *ecdh.PrivateKey + sessionC2S []byte + sessionS2C []byte + responseSet bool +} + +type EarlyServerState struct { + ResponsePayload []byte + UserHash string + + cfg EarlyCodecConfig + table *sudokuobfs.Table + sessionC2S []byte + sessionS2C []byte +} + +type ReplayAllowFunc func(userHash string, nonce [kipHelloNonceSize]byte, now time.Time) bool + +type earlyMemoryConn struct { + reader *bytes.Reader + write bytes.Buffer +} + +func newEarlyMemoryConn(readBuf []byte) *earlyMemoryConn { + return &earlyMemoryConn{reader: bytes.NewReader(readBuf)} +} + +func (c *earlyMemoryConn) Read(p []byte) (int, error) { + if c == nil || c.reader == nil { + return 0, net.ErrClosed + } + return c.reader.Read(p) +} + +func (c *earlyMemoryConn) Write(p []byte) (int, error) { + if c == nil { + return 0, net.ErrClosed + } + return c.write.Write(p) +} + +func (c *earlyMemoryConn) Close() error { return nil } +func (c *earlyMemoryConn) LocalAddr() net.Addr { return earlyDummyAddr("local") } +func (c *earlyMemoryConn) RemoteAddr() net.Addr { return earlyDummyAddr("remote") } +func (c *earlyMemoryConn) SetDeadline(time.Time) error { return nil } +func (c *earlyMemoryConn) SetReadDeadline(time.Time) error { return nil } +func (c *earlyMemoryConn) SetWriteDeadline(time.Time) error { return nil } +func (c *earlyMemoryConn) Written() []byte { return append([]byte(nil), c.write.Bytes()...) } + +type earlyDummyAddr string + +func (a earlyDummyAddr) Network() string { return string(a) } +func (a earlyDummyAddr) String() string { return string(a) } + +func buildEarlyClientObfsConn(raw net.Conn, cfg EarlyCodecConfig, table *sudokuobfs.Table) net.Conn { + base := sudokuobfs.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false) + downlinkReader := newClientDownlinkReader(raw, table, cfg.PaddingMin, cfg.PaddingMax, cfg.EnablePureDownlink) + if downlinkReader == nil { + return base + } + return newDirectionalConn(raw, downlinkReader, base) +} + +func buildEarlyServerObfsConn(raw net.Conn, cfg EarlyCodecConfig, table *sudokuobfs.Table) net.Conn { + uplink := sudokuobfs.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false) + downlinkWriter, closers := newServerDownlinkWriter(raw, table, cfg.PaddingMin, cfg.PaddingMax, cfg.EnablePureDownlink) + if downlinkWriter == nil { + return uplink + } + return newDirectionalConn(raw, uplink, downlinkWriter, closers...) +} + +func NewEarlyClientState(cfg EarlyCodecConfig, table *sudokuobfs.Table, tableHint uint32, hasTableHint bool, userHash [kipHelloUserHashSize]byte, feats uint32) (*EarlyClientState, error) { + if table == nil { + return nil, fmt.Errorf("nil table") + } + + curve := ecdh.X25519() + ephemeral, err := curve.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("ecdh generate failed: %w", err) + } + + var nonce [kipHelloNonceSize]byte + if _, err := rand.Read(nonce[:]); err != nil { + return nil, fmt.Errorf("nonce generate failed: %w", err) + } + + var clientPub [kipHelloPubSize]byte + copy(clientPub[:], ephemeral.PublicKey().Bytes()) + hello := newKIPClientHello(userHash, nonce, clientPub, feats, tableHint, hasTableHint) + + mem := newEarlyMemoryConn(nil) + obfsConn := buildEarlyClientObfsConn(mem, cfg, table) + pskC2S, pskS2C := derivePSKDirectionalBases(cfg.PSK) + rc, err := crypto.NewRecordConn(obfsConn, cfg.AEAD, pskC2S, pskS2C) + if err != nil { + return nil, fmt.Errorf("client early crypto setup failed: %w", err) + } + if err := WriteKIPMessage(rc, KIPTypeClientHello, hello.EncodePayload()); err != nil { + return nil, fmt.Errorf("write early client hello failed: %w", err) + } + + return &EarlyClientState{ + RequestPayload: mem.Written(), + cfg: cfg, + table: table, + nonce: nonce, + ephemeral: ephemeral, + }, nil +} + +func (s *EarlyClientState) ProcessResponse(payload []byte) error { + if s == nil { + return fmt.Errorf("nil client state") + } + + mem := newEarlyMemoryConn(payload) + obfsConn := buildEarlyClientObfsConn(mem, s.cfg, s.table) + pskC2S, pskS2C := derivePSKDirectionalBases(s.cfg.PSK) + rc, err := crypto.NewRecordConn(obfsConn, s.cfg.AEAD, pskC2S, pskS2C) + if err != nil { + return fmt.Errorf("client early crypto setup failed: %w", err) + } + + msg, err := ReadKIPMessage(rc) + if err != nil { + return fmt.Errorf("read early server hello failed: %w", err) + } + if msg.Type != KIPTypeServerHello { + return fmt.Errorf("unexpected early handshake message: %d", msg.Type) + } + sh, err := DecodeKIPServerHelloPayload(msg.Payload) + if err != nil { + return fmt.Errorf("decode early server hello failed: %w", err) + } + if sh.Nonce != s.nonce { + return fmt.Errorf("early handshake nonce mismatch") + } + + shared, err := x25519SharedSecret(s.ephemeral, sh.ServerPub[:]) + if err != nil { + return fmt.Errorf("ecdh failed: %w", err) + } + s.sessionC2S, s.sessionS2C, err = deriveSessionDirectionalBases(s.cfg.PSK, shared, s.nonce) + if err != nil { + return fmt.Errorf("derive session keys failed: %w", err) + } + s.responseSet = true + return nil +} + +func (s *EarlyClientState) WrapConn(raw net.Conn) (net.Conn, error) { + if s == nil { + return nil, fmt.Errorf("nil client state") + } + if !s.responseSet { + return nil, fmt.Errorf("early handshake not completed") + } + + obfsConn := buildEarlyClientObfsConn(raw, s.cfg, s.table) + rc, err := crypto.NewRecordConn(obfsConn, s.cfg.AEAD, s.sessionC2S, s.sessionS2C) + if err != nil { + return nil, fmt.Errorf("setup client session crypto failed: %w", err) + } + return rc, nil +} + +func (s *EarlyClientState) Ready() bool { + return s != nil && s.responseSet +} + +func NewHTTPMaskClientEarlyHandshake(cfg EarlyCodecConfig, table *sudokuobfs.Table, tableHint uint32, hasTableHint bool, userHash [kipHelloUserHashSize]byte, feats uint32) (*httpmaskobfs.ClientEarlyHandshake, error) { + state, err := NewEarlyClientState(cfg, table, tableHint, hasTableHint, userHash, feats) + if err != nil { + return nil, err + } + return &httpmaskobfs.ClientEarlyHandshake{ + RequestPayload: state.RequestPayload, + HandleResponse: state.ProcessResponse, + Ready: state.Ready, + WrapConn: state.WrapConn, + }, nil +} + +func ProcessEarlyClientPayload(cfg EarlyCodecConfig, tables []*sudokuobfs.Table, payload []byte, allowReplay ReplayAllowFunc) (*EarlyServerState, error) { + if len(payload) == 0 { + return nil, fmt.Errorf("empty early payload") + } + if len(tables) == 0 { + return nil, fmt.Errorf("no tables configured") + } + + var firstErr error + for _, table := range tables { + state, err := processEarlyClientPayloadForTable(cfg, tables, table, payload, allowReplay) + if err == nil { + return state, nil + } + if firstErr == nil { + firstErr = err + } + } + if firstErr == nil { + firstErr = fmt.Errorf("early handshake probe failed") + } + return nil, firstErr +} + +func processEarlyClientPayloadForTable(cfg EarlyCodecConfig, tables []*sudokuobfs.Table, table *sudokuobfs.Table, payload []byte, allowReplay ReplayAllowFunc) (*EarlyServerState, error) { + mem := newEarlyMemoryConn(payload) + obfsConn := buildEarlyServerObfsConn(mem, cfg, table) + pskC2S, pskS2C := derivePSKDirectionalBases(cfg.PSK) + rc, err := crypto.NewRecordConn(obfsConn, cfg.AEAD, pskS2C, pskC2S) + if err != nil { + return nil, err + } + + msg, err := ReadKIPMessage(rc) + if err != nil { + return nil, err + } + if msg.Type != KIPTypeClientHello { + return nil, fmt.Errorf("unexpected handshake message: %d", msg.Type) + } + ch, err := DecodeKIPClientHelloPayload(msg.Payload) + if err != nil { + return nil, err + } + if absInt64(time.Now().Unix()-ch.Timestamp.Unix()) > int64(earlyKIPHandshakeTTL.Seconds()) { + return nil, fmt.Errorf("time skew/replay") + } + + userHash := hex.EncodeToString(ch.UserHash[:]) + if allowReplay != nil && !allowReplay(userHash, ch.Nonce, time.Now()) { + return nil, fmt.Errorf("replay detected") + } + resolvedTable, err := ResolveClientHelloTable(table, tables, ch) + if err != nil { + return nil, fmt.Errorf("resolve table hint failed: %w", err) + } + + curve := ecdh.X25519() + serverEphemeral, err := curve.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("ecdh generate failed: %w", err) + } + shared, err := x25519SharedSecret(serverEphemeral, ch.ClientPub[:]) + if err != nil { + return nil, fmt.Errorf("ecdh failed: %w", err) + } + sessionC2S, sessionS2C, err := deriveSessionDirectionalBases(cfg.PSK, shared, ch.Nonce) + if err != nil { + return nil, fmt.Errorf("derive session keys failed: %w", err) + } + + var serverPub [kipHelloPubSize]byte + copy(serverPub[:], serverEphemeral.PublicKey().Bytes()) + serverHello := &KIPServerHello{ + Nonce: ch.Nonce, + ServerPub: serverPub, + SelectedFeats: ch.Features & KIPFeatAll, + } + + respMem := newEarlyMemoryConn(nil) + respObfs := buildEarlyServerObfsConn(respMem, cfg, resolvedTable) + respConn, err := crypto.NewRecordConn(respObfs, cfg.AEAD, pskS2C, pskC2S) + if err != nil { + return nil, fmt.Errorf("server early crypto setup failed: %w", err) + } + if err := WriteKIPMessage(respConn, KIPTypeServerHello, serverHello.EncodePayload()); err != nil { + return nil, fmt.Errorf("write early server hello failed: %w", err) + } + + return &EarlyServerState{ + ResponsePayload: respMem.Written(), + UserHash: userHash, + cfg: cfg, + table: resolvedTable, + sessionC2S: sessionC2S, + sessionS2C: sessionS2C, + }, nil +} + +func (s *EarlyServerState) WrapConn(raw net.Conn) (net.Conn, error) { + if s == nil { + return nil, fmt.Errorf("nil server state") + } + obfsConn := buildEarlyServerObfsConn(raw, s.cfg, s.table) + rc, err := crypto.NewRecordConn(obfsConn, s.cfg.AEAD, s.sessionS2C, s.sessionC2S) + if err != nil { + return nil, fmt.Errorf("setup server session crypto failed: %w", err) + } + return rc, nil +} + +func NewHTTPMaskServerEarlyHandshake(cfg EarlyCodecConfig, tables []*sudokuobfs.Table, allowReplay ReplayAllowFunc) *httpmaskobfs.TunnelServerEarlyHandshake { + return &httpmaskobfs.TunnelServerEarlyHandshake{ + Prepare: func(payload []byte) (*httpmaskobfs.PreparedServerEarlyHandshake, error) { + state, err := ProcessEarlyClientPayload(cfg, tables, payload, allowReplay) + if err != nil { + return nil, err + } + return &httpmaskobfs.PreparedServerEarlyHandshake{ + ResponsePayload: state.ResponsePayload, + WrapConn: state.WrapConn, + UserHash: state.UserHash, + }, nil + }, + } +} diff --git a/transport/sudoku/handshake.go b/transport/sudoku/handshake.go new file mode 100644 index 00000000..e2c173ee --- /dev/null +++ b/transport/sudoku/handshake.go @@ -0,0 +1,511 @@ +package sudoku + +import ( + "bufio" + "bytes" + "crypto/ecdh" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "net" + "strings" + "sync" + "time" + + "github.com/sagernet/sing-box/transport/sudoku/crypto" + "github.com/sagernet/sing-box/transport/sudoku/obfs/httpmask" + "github.com/sagernet/sing-box/transport/sudoku/obfs/sudoku" +) + +type SessionType int + +const ( + SessionTypeTCP SessionType = iota + SessionTypeUoT + SessionTypeMultiplex +) + +type ServerSession struct { + Conn net.Conn + Type SessionType + Target string + + // UserHash is a stable per-key identifier derived from the client hello payload. + UserHash string +} + +type HandshakeMeta struct { + UserHash string +} + +// SuspiciousError indicates a potential probing attempt or protocol violation. +// When returned, Conn (if non-nil) should contain all bytes already consumed/buffered so the caller +// can perform a best-effort fallback relay (e.g. to a local web server) without losing the request. +type SuspiciousError struct { + Err error + Conn net.Conn +} + +func (e *SuspiciousError) Error() string { + if e == nil || e.Err == nil { + return "" + } + return e.Err.Error() +} + +func (e *SuspiciousError) Unwrap() error { return e.Err } + +type recordedConn struct { + net.Conn + recorded []byte +} + +func (rc *recordedConn) GetBufferedAndRecorded() []byte { return rc.recorded } + +type prefixedRecorderConn struct { + net.Conn + prefix []byte +} + +func (pc *prefixedRecorderConn) GetBufferedAndRecorded() []byte { + var rest []byte + if r, ok := pc.Conn.(interface{ GetBufferedAndRecorded() []byte }); ok { + rest = r.GetBufferedAndRecorded() + } + out := make([]byte, 0, len(pc.prefix)+len(rest)) + out = append(out, pc.prefix...) + out = append(out, rest...) + return out +} + +// bufferedRecorderConn wraps a net.Conn and a shared bufio.Reader so we can expose buffered bytes. +// This is used for legacy HTTP mask parsing errors so callers can fall back to a real HTTP server. +type bufferedRecorderConn struct { + net.Conn + r *bufio.Reader + recorder *bytes.Buffer + mu sync.Mutex +} + +func (bc *bufferedRecorderConn) Read(p []byte) (n int, err error) { + n, err = bc.r.Read(p) + if n > 0 && bc.recorder != nil { + bc.mu.Lock() + bc.recorder.Write(p[:n]) + bc.mu.Unlock() + } + return n, err +} + +func (bc *bufferedRecorderConn) GetBufferedAndRecorded() []byte { + if bc == nil { + return nil + } + bc.mu.Lock() + defer bc.mu.Unlock() + + var recorded []byte + if bc.recorder != nil { + recorded = bc.recorder.Bytes() + } + buffered := 0 + if bc.r != nil { + buffered = bc.r.Buffered() + } + if buffered <= 0 { + return recorded + } + peeked, _ := bc.r.Peek(buffered) + full := make([]byte, len(recorded)+len(peeked)) + copy(full, recorded) + copy(full[len(recorded):], peeked) + return full +} + +type preBufferedConn struct { + net.Conn + buf []byte +} + +func (p *preBufferedConn) Read(b []byte) (int, error) { + if len(p.buf) > 0 { + n := copy(b, p.buf) + p.buf = p.buf[n:] + return n, nil + } + if p.Conn == nil { + return 0, io.EOF + } + return p.Conn.Read(b) +} + +func (p *preBufferedConn) CloseWrite() error { + if p == nil { + return nil + } + if cw, ok := p.Conn.(interface{ CloseWrite() error }); ok { + return cw.CloseWrite() + } + return nil +} + +func (p *preBufferedConn) CloseRead() error { + if p == nil { + return nil + } + if cr, ok := p.Conn.(interface{ CloseRead() error }); ok { + return cr.CloseRead() + } + return nil +} + +type directionalConn struct { + net.Conn + reader io.Reader + writer io.Writer + closers []func() error +} + +func newDirectionalConn(base net.Conn, reader io.Reader, writer io.Writer, closers ...func() error) net.Conn { + return &directionalConn{ + Conn: base, + reader: reader, + writer: writer, + closers: closers, + } +} + +func (c *directionalConn) Read(p []byte) (int, error) { + return c.reader.Read(p) +} + +func (c *directionalConn) Write(p []byte) (int, error) { + return c.writer.Write(p) +} + +func (c *directionalConn) ReplaceWriter(writer io.Writer, closers ...func() error) { + if c == nil { + return + } + c.writer = writer + c.closers = closers +} + +func (c *directionalConn) Close() error { + var firstErr error + for _, fn := range c.closers { + if fn == nil { + continue + } + if err := fn(); err != nil && firstErr == nil { + firstErr = err + } + } + if err := c.Conn.Close(); err != nil && firstErr == nil { + firstErr = err + } + return firstErr +} + +func (c *directionalConn) CloseWrite() error { + if c == nil { + return nil + } + if cw, ok := c.Conn.(interface{ CloseWrite() error }); ok { + return cw.CloseWrite() + } + return nil +} + +func (c *directionalConn) CloseRead() error { + if c == nil { + return nil + } + if cr, ok := c.Conn.(interface{ CloseRead() error }); ok { + return cr.CloseRead() + } + return nil +} + +func absInt64(v int64) int64 { + if v < 0 { + return -v + } + return v +} + +func oppositeDirectionTable(table *sudoku.Table) *sudoku.Table { + if table == nil { + return nil + } + if other := table.OppositeDirection(); other != nil { + return other + } + return table +} + +func newClientDownlinkReader(raw net.Conn, table *sudoku.Table, paddingMin, paddingMax int, pureDownlink bool) io.Reader { + downlinkTable := oppositeDirectionTable(table) + if pureDownlink { + if downlinkTable == table { + return nil + } + return sudoku.NewConn(raw, downlinkTable, paddingMin, paddingMax, false) + } + return sudoku.NewPackedConn(raw, downlinkTable, paddingMin, paddingMax) +} + +func newServerDownlinkWriter(raw net.Conn, table *sudoku.Table, paddingMin, paddingMax int, pureDownlink bool) (io.Writer, []func() error) { + downlinkTable := oppositeDirectionTable(table) + if pureDownlink { + if downlinkTable == table { + return nil, nil + } + return sudoku.NewConn(raw, downlinkTable, paddingMin, paddingMax, false), nil + } + packed := sudoku.NewPackedConn(raw, downlinkTable, paddingMin, paddingMax) + return packed, []func() error{packed.Flush} +} + +func buildClientObfsConn(raw net.Conn, cfg *ProtocolConfig, table *sudoku.Table) net.Conn { + baseSudoku := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false) + downlinkReader := newClientDownlinkReader(raw, table, cfg.PaddingMin, cfg.PaddingMax, cfg.EnablePureDownlink) + if downlinkReader == nil { + return baseSudoku + } + return newDirectionalConn(raw, downlinkReader, baseSudoku) +} + +func buildServerObfsConn(raw net.Conn, cfg *ProtocolConfig, table *sudoku.Table, record bool) (*sudoku.Conn, net.Conn) { + uplinkSudoku := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, record) + downlinkWriter, closers := newServerDownlinkWriter(raw, table, cfg.PaddingMin, cfg.PaddingMax, cfg.EnablePureDownlink) + if downlinkWriter == nil { + return uplinkSudoku, uplinkSudoku + } + return uplinkSudoku, newDirectionalConn(raw, uplinkSudoku, downlinkWriter, closers...) +} + +func isLegacyHTTPMaskMode(mode string) bool { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "", "legacy": + return true + default: + return false + } +} + +// ClientHandshake performs the client-side Sudoku handshake (no target request). +func ClientHandshake(rawConn net.Conn, cfg *ProtocolConfig) (net.Conn, error) { + if cfg == nil { + return nil, fmt.Errorf("config is required") + } + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + if !cfg.DisableHTTPMask && isLegacyHTTPMaskMode(cfg.HTTPMaskMode) { + if err := httpmask.WriteRandomRequestHeaderWithPathRoot(rawConn, cfg.ServerAddress, cfg.HTTPMaskPathRoot); err != nil { + return nil, fmt.Errorf("write http mask failed: %w", err) + } + } + + choice, err := pickClientTable(cfg) + if err != nil { + return nil, err + } + + seed := ClientAEADSeed(cfg.Key) + obfsConn := buildClientObfsConn(rawConn, cfg, choice.Table) + pskC2S, pskS2C := derivePSKDirectionalBases(seed) + rc, err := crypto.NewRecordConn(obfsConn, cfg.AEADMethod, pskC2S, pskS2C) + if err != nil { + return nil, fmt.Errorf("setup crypto failed: %w", err) + } + + if _, err := kipHandshakeClient(rc, seed, kipUserHashFromKey(cfg.Key), KIPFeatAll, choice.Hint, choice.HasHint); err != nil { + _ = rc.Close() + return nil, err + } + + return rc, nil +} + +func readFirstSessionMessage(conn net.Conn) (*KIPMessage, error) { + for { + msg, err := ReadKIPMessage(conn) + if err != nil { + return nil, err + } + if msg.Type == KIPTypeKeepAlive { + continue + } + return msg, nil + } +} + +func maybeConsumeLegacyHTTPMask(rawConn net.Conn, r *bufio.Reader, cfg *ProtocolConfig) ([]byte, *SuspiciousError) { + if rawConn == nil || r == nil || cfg == nil || cfg.DisableHTTPMask || !isLegacyHTTPMaskMode(cfg.HTTPMaskMode) { + return nil, nil + } + + peekBytes, _ := r.Peek(4) // ignore error; subsequent read will handle it + if !httpmask.LooksLikeHTTPRequestStart(peekBytes) { + return nil, nil + } + + consumed, err := httpmask.ConsumeHeader(r) + if err == nil { + return consumed, nil + } + + recorder := new(bytes.Buffer) + if len(consumed) > 0 { + recorder.Write(consumed) + } + badConn := &bufferedRecorderConn{Conn: rawConn, r: r, recorder: recorder} + return consumed, &SuspiciousError{Err: fmt.Errorf("invalid http header: %w", err), Conn: badConn} +} + +// ServerHandshake performs the server-side KIP handshake. +func ServerHandshake(rawConn net.Conn, cfg *ProtocolConfig) (net.Conn, *HandshakeMeta, error) { + if rawConn == nil { + return nil, nil, fmt.Errorf("nil conn") + } + if cfg == nil { + return nil, nil, fmt.Errorf("config is required") + } + if err := cfg.Validate(); err != nil { + return nil, nil, fmt.Errorf("invalid config: %w", err) + } + if userHash, ok := httpmask.EarlyHandshakeUserHash(rawConn); ok { + return rawConn, &HandshakeMeta{UserHash: userHash}, nil + } + + handshakeTimeout := time.Duration(cfg.HandshakeTimeoutSeconds) * time.Second + if handshakeTimeout <= 0 { + handshakeTimeout = 5 * time.Second + } + + bufReader := bufio.NewReader(rawConn) + _ = rawConn.SetReadDeadline(time.Now().Add(handshakeTimeout)) + defer func() { _ = rawConn.SetReadDeadline(time.Time{}) }() + + httpHeaderData, susp := maybeConsumeLegacyHTTPMask(rawConn, bufReader, cfg) + if susp != nil { + return nil, nil, susp + } + + selectedTable, preRead, err := selectTableByProbe(bufReader, cfg, cfg.tableCandidates()) + if err != nil { + combined := make([]byte, 0, len(httpHeaderData)+len(preRead)) + combined = append(combined, httpHeaderData...) + combined = append(combined, preRead...) + return nil, nil, &SuspiciousError{Err: err, Conn: &recordedConn{Conn: rawConn, recorded: combined}} + } + + baseConn := &preBufferedConn{Conn: rawConn, buf: preRead} + sConn, obfsConn := buildServerObfsConn(baseConn, cfg, selectedTable, true) + + seed := ServerAEADSeed(cfg.Key) + pskC2S, pskS2C := derivePSKDirectionalBases(seed) + // Server side: recv is client->server, send is server->client. + rc, err := crypto.NewRecordConn(obfsConn, cfg.AEADMethod, pskS2C, pskC2S) + if err != nil { + return nil, nil, fmt.Errorf("setup crypto failed: %w", err) + } + + msg, err := ReadKIPMessage(rc) + if err != nil { + return nil, nil, &SuspiciousError{Err: fmt.Errorf("handshake read failed: %w", err), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}} + } + if msg.Type != KIPTypeClientHello { + return nil, nil, &SuspiciousError{Err: fmt.Errorf("unexpected handshake message: %d", msg.Type), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}} + } + ch, err := DecodeKIPClientHelloPayload(msg.Payload) + if err != nil { + return nil, nil, &SuspiciousError{Err: fmt.Errorf("decode client hello failed: %w", err), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}} + } + if absInt64(time.Now().Unix()-ch.Timestamp.Unix()) > int64(kipHandshakeSkew.Seconds()) { + return nil, nil, &SuspiciousError{Err: fmt.Errorf("time skew/replay"), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}} + } + + userHashHex := hex.EncodeToString(ch.UserHash[:]) + if !globalHandshakeReplay.allow(userHashHex, ch.Nonce, time.Now()) { + return nil, nil, &SuspiciousError{Err: fmt.Errorf("replay"), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}} + } + resolvedTable, err := ResolveClientHelloTable(selectedTable, cfg.tableCandidates(), ch) + if err != nil { + return nil, nil, &SuspiciousError{Err: fmt.Errorf("resolve table hint failed: %w", err), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}} + } + if resolvedTable != selectedTable { + downlinkWriter, closers := newServerDownlinkWriter(baseConn, resolvedTable, cfg.PaddingMin, cfg.PaddingMax, cfg.EnablePureDownlink) + switchable, ok := obfsConn.(*directionalConn) + if !ok { + return nil, nil, &SuspiciousError{Err: fmt.Errorf("switch downlink writer failed"), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}} + } + switchable.ReplaceWriter(downlinkWriter, closers...) + } + + curve := ecdh.X25519() + serverEphemeral, err := curve.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, &SuspiciousError{Err: fmt.Errorf("ecdh generate failed: %w", err), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}} + } + shared, err := x25519SharedSecret(serverEphemeral, ch.ClientPub[:]) + if err != nil { + return nil, nil, &SuspiciousError{Err: fmt.Errorf("ecdh failed: %w", err), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}} + } + sessC2S, sessS2C, err := deriveSessionDirectionalBases(seed, shared, ch.Nonce) + if err != nil { + return nil, nil, &SuspiciousError{Err: fmt.Errorf("derive session keys failed: %w", err), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}} + } + + var serverPub [kipHelloPubSize]byte + copy(serverPub[:], serverEphemeral.PublicKey().Bytes()) + sh := &KIPServerHello{ + Nonce: ch.Nonce, + ServerPub: serverPub, + SelectedFeats: ch.Features & KIPFeatAll, + } + if err := WriteKIPMessage(rc, KIPTypeServerHello, sh.EncodePayload()); err != nil { + return nil, nil, &SuspiciousError{Err: fmt.Errorf("write server hello failed: %w", err), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}} + } + if err := rc.Rekey(sessS2C, sessC2S); err != nil { + return nil, nil, &SuspiciousError{Err: fmt.Errorf("rekey failed: %w", err), Conn: &prefixedRecorderConn{Conn: sConn, prefix: httpHeaderData}} + } + + sConn.StopRecording() + return rc, &HandshakeMeta{UserHash: userHashHex}, nil +} + +// ReadServerSession consumes the first post-handshake KIP control message and returns the session intent. +func ReadServerSession(conn net.Conn, meta *HandshakeMeta) (*ServerSession, error) { + if conn == nil { + return nil, fmt.Errorf("nil conn") + } + userHash := "" + if meta != nil { + userHash = meta.UserHash + } + + first, err := readFirstSessionMessage(conn) + if err != nil { + return nil, err + } + + switch first.Type { + case KIPTypeStartUoT: + return &ServerSession{Conn: conn, Type: SessionTypeUoT, UserHash: userHash}, nil + case KIPTypeStartMux: + return &ServerSession{Conn: conn, Type: SessionTypeMultiplex, UserHash: userHash}, nil + case KIPTypeOpenTCP: + target, err := DecodeAddress(bytes.NewReader(first.Payload)) + if err != nil { + return nil, fmt.Errorf("decode target address failed: %w", err) + } + return &ServerSession{Conn: conn, Type: SessionTypeTCP, Target: target, UserHash: userHash}, nil + default: + return nil, fmt.Errorf("unknown kip message: %d", first.Type) + } +} diff --git a/transport/sudoku/handshake_kip.go b/transport/sudoku/handshake_kip.go new file mode 100644 index 00000000..2836b5a9 --- /dev/null +++ b/transport/sudoku/handshake_kip.go @@ -0,0 +1,67 @@ +package sudoku + +import ( + "crypto/ecdh" + "crypto/rand" + "fmt" + "io" + "time" + + "github.com/sagernet/sing-box/transport/sudoku/crypto" +) + +const kipHandshakeSkew = 60 * time.Second + +func kipHandshakeClient(rc *crypto.RecordConn, seed string, userHash [kipHelloUserHashSize]byte, feats uint32, tableHint uint32, hasTableHint bool) (uint32, error) { + if rc == nil { + return 0, fmt.Errorf("nil conn") + } + + curve := ecdh.X25519() + ephemeral, err := curve.GenerateKey(rand.Reader) + if err != nil { + return 0, fmt.Errorf("ecdh generate failed: %w", err) + } + + var nonce [kipHelloNonceSize]byte + if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil { + return 0, fmt.Errorf("nonce generate failed: %w", err) + } + + var clientPub [kipHelloPubSize]byte + copy(clientPub[:], ephemeral.PublicKey().Bytes()) + + ch := newKIPClientHello(userHash, nonce, clientPub, feats, tableHint, hasTableHint) + if err := WriteKIPMessage(rc, KIPTypeClientHello, ch.EncodePayload()); err != nil { + return 0, fmt.Errorf("write client hello failed: %w", err) + } + + msg, err := ReadKIPMessage(rc) + if err != nil { + return 0, fmt.Errorf("read server hello failed: %w", err) + } + if msg.Type != KIPTypeServerHello { + return 0, fmt.Errorf("unexpected handshake message: %d", msg.Type) + } + sh, err := DecodeKIPServerHelloPayload(msg.Payload) + if err != nil { + return 0, fmt.Errorf("decode server hello failed: %w", err) + } + if sh.Nonce != nonce { + return 0, fmt.Errorf("handshake nonce mismatch") + } + + shared, err := x25519SharedSecret(ephemeral, sh.ServerPub[:]) + if err != nil { + return 0, fmt.Errorf("ecdh failed: %w", err) + } + sessC2S, sessS2C, err := deriveSessionDirectionalBases(seed, shared, nonce) + if err != nil { + return 0, fmt.Errorf("derive session keys failed: %w", err) + } + if err := rc.Rekey(sessC2S, sessS2C); err != nil { + return 0, fmt.Errorf("rekey failed: %w", err) + } + + return sh.SelectedFeats, nil +} diff --git a/transport/sudoku/httpmask_tunnel.go b/transport/sudoku/httpmask_tunnel.go new file mode 100644 index 00000000..947ae434 --- /dev/null +++ b/transport/sudoku/httpmask_tunnel.go @@ -0,0 +1,155 @@ +package sudoku + +import ( + "context" + "fmt" + "net" + "strings" + + "github.com/sagernet/sing-box/transport/sudoku/obfs/httpmask" +) + +type HTTPMaskTunnelServer struct { + cfg *ProtocolConfig + ts *httpmask.TunnelServer +} + +func newHTTPMaskEarlyCodecConfig(cfg *ProtocolConfig, psk string) EarlyCodecConfig { + return EarlyCodecConfig{ + PSK: psk, + AEAD: cfg.AEADMethod, + EnablePureDownlink: cfg.EnablePureDownlink, + PaddingMin: cfg.PaddingMin, + PaddingMax: cfg.PaddingMax, + } +} + +func newClientHTTPMaskEarlyHandshake(cfg *ProtocolConfig) (*httpmask.ClientEarlyHandshake, error) { + choice, err := pickClientTable(cfg) + if err != nil { + return nil, err + } + + return NewHTTPMaskClientEarlyHandshake( + newHTTPMaskEarlyCodecConfig(cfg, ClientAEADSeed(cfg.Key)), + choice.Table, + choice.Hint, + choice.HasHint, + kipUserHashFromKey(cfg.Key), + KIPFeatAll, + ) +} + +func NewHTTPMaskTunnelServer(cfg *ProtocolConfig) *HTTPMaskTunnelServer { + return newHTTPMaskTunnelServer(cfg, false) +} + +func NewHTTPMaskTunnelServerWithFallback(cfg *ProtocolConfig) *HTTPMaskTunnelServer { + return newHTTPMaskTunnelServer(cfg, true) +} + +func newHTTPMaskTunnelServer(cfg *ProtocolConfig, passThroughOnReject bool) *HTTPMaskTunnelServer { + if cfg == nil { + return &HTTPMaskTunnelServer{} + } + + var ts *httpmask.TunnelServer + if !cfg.DisableHTTPMask { + switch strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) { + case "stream", "poll", "auto", "ws": + ts = httpmask.NewTunnelServer(httpmask.TunnelServerOptions{ + Mode: cfg.HTTPMaskMode, + PathRoot: cfg.HTTPMaskPathRoot, + AuthKey: ServerAEADSeed(cfg.Key), + EarlyHandshake: NewHTTPMaskServerEarlyHandshake( + newHTTPMaskEarlyCodecConfig(cfg, ServerAEADSeed(cfg.Key)), + cfg.tableCandidates(), + globalHandshakeReplay.allow, + ), + // When upstream fallback is enabled, preserve rejected HTTP requests for the caller. + PassThroughOnReject: passThroughOnReject, + }) + } + } + return &HTTPMaskTunnelServer{cfg: cfg, ts: ts} +} + +// WrapConn inspects an accepted TCP connection and upgrades it to an HTTP tunnel stream when needed. +// +// Returns: +// - done=true: this TCP connection has been fully handled (e.g., stream/poll control request), caller should return +// - done=false: handshakeConn+cfg are ready for ServerHandshake +func (s *HTTPMaskTunnelServer) WrapConn(rawConn net.Conn) (handshakeConn net.Conn, cfg *ProtocolConfig, done bool, err error) { + if rawConn == nil { + return nil, nil, true, fmt.Errorf("nil conn") + } + if s == nil { + return rawConn, nil, false, nil + } + if s.ts == nil { + return rawConn, s.cfg, false, nil + } + + res, c, err := s.ts.HandleConn(rawConn) + if err != nil { + return nil, nil, true, err + } + + switch res { + case httpmask.HandleDone: + return nil, nil, true, nil + case httpmask.HandlePassThrough: + return c, s.cfg, false, nil + case httpmask.HandleStartTunnel: + inner := *s.cfg + inner.DisableHTTPMask = true + // HTTPMask tunnel modes (stream/poll/auto/ws) add extra round trips before the first + // handshake bytes can reach ServerHandshake, especially under high concurrency. + // Bump the handshake timeout for tunneled conns to avoid flaky timeouts while keeping + // the default strict for raw TCP handshakes. + const minTunneledHandshakeTimeoutSeconds = 15 + if inner.HandshakeTimeoutSeconds <= 0 || inner.HandshakeTimeoutSeconds < minTunneledHandshakeTimeoutSeconds { + inner.HandshakeTimeoutSeconds = minTunneledHandshakeTimeoutSeconds + } + return c, &inner, false, nil + default: + return nil, nil, true, nil + } +} + +type TunnelDialer func(ctx context.Context, network, addr string) (net.Conn, error) + +// DialHTTPMaskTunnel dials a CDN-capable HTTP tunnel (stream/poll/auto/ws) and returns a stream carrying raw Sudoku bytes. +func DialHTTPMaskTunnel(ctx context.Context, serverAddress string, cfg *ProtocolConfig, dial TunnelDialer, upgrade func(net.Conn) (net.Conn, error)) (net.Conn, error) { + if cfg == nil { + return nil, fmt.Errorf("config is required") + } + if cfg.DisableHTTPMask { + return nil, fmt.Errorf("http mask is disabled") + } + switch strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) { + case "stream", "poll", "auto", "ws": + default: + return nil, fmt.Errorf("http-mask-mode=%q does not use http tunnel", cfg.HTTPMaskMode) + } + var ( + earlyHandshake *httpmask.ClientEarlyHandshake + err error + ) + if upgrade != nil { + earlyHandshake, err = newClientHTTPMaskEarlyHandshake(cfg) + if err != nil { + return nil, err + } + } + return httpmask.DialTunnel(ctx, serverAddress, httpmask.TunnelDialOptions{ + Mode: cfg.HTTPMaskMode, + HostOverride: cfg.HTTPMaskHost, + PathRoot: cfg.HTTPMaskPathRoot, + AuthKey: ClientAEADSeed(cfg.Key), + EarlyHandshake: earlyHandshake, + Upgrade: upgrade, + Multiplex: cfg.HTTPMaskMultiplex, + DialContext: dial, + }) +} diff --git a/transport/sudoku/init.go b/transport/sudoku/init.go new file mode 100644 index 00000000..3e65591f --- /dev/null +++ b/transport/sudoku/init.go @@ -0,0 +1,101 @@ +package sudoku + +import ( + "encoding/hex" + "fmt" + "strings" + + "filippo.io/edwards25519" + "github.com/sagernet/sing-box/transport/sudoku/crypto" + "github.com/sagernet/sing-box/transport/sudoku/obfs/sudoku" +) + +func NewTable(key string, tableType string) *sudoku.Table { + table, err := NewTableWithCustom(key, tableType, "") + if err != nil { + panic(fmt.Sprintf("[Sudoku] failed to init tables: %v", err)) + } + return table +} + +func NewTableWithCustom(key string, tableType string, customTable string) (*sudoku.Table, error) { + table, err := sudoku.NewTableWithCustom(key, tableType, customTable) + if err != nil { + return nil, err + } + return table, nil +} + +// ClientAEADSeed returns a canonical "seed" that is stable between client private key material and server public key. +func ClientAEADSeed(key string) string { + key = strings.TrimSpace(key) + if key == "" { + return "" + } + + b, err := hex.DecodeString(key) + if err != nil { + return key + } + + // Client-side key material can be: + // - public key: 32 bytes hex compressed point + // - split private key: 64 bytes hex (r||k) + // - master private scalar: 32 bytes hex (x) + // - PSK string: non-hex + // + // 32-byte hex is ambiguous: it can be either a compressed public key or a + // master private scalar. Official Sudoku runtime accepts public keys directly, + // so when the bytes already decode as a point, preserve that point verbatim. + if len(b) == 32 { + if p, err := new(edwards25519.Point).SetBytes(b); err == nil { + return hex.EncodeToString(p.Bytes()) + } + } + if len(b) != 64 && len(b) != 32 { + return key + } + if recovered, err := crypto.RecoverPublicKey(key); err == nil { + return crypto.EncodePoint(recovered) + } + return key +} + +// ServerAEADSeed returns a canonical seed for server-side configuration. +// +// When key is a public key (32-byte compressed point, hex), it returns the canonical point encoding. +// When key is private key material (split/master scalar), it derives and returns the public key. +func ServerAEADSeed(key string) string { + key = strings.TrimSpace(key) + if key == "" { + return "" + } + + b, err := hex.DecodeString(key) + if err != nil { + return key + } + + // Prefer interpreting 32-byte hex as a public key point, to avoid accidental scalar parsing. + if len(b) == 32 { + if p, err := new(edwards25519.Point).SetBytes(b); err == nil { + return hex.EncodeToString(p.Bytes()) + } + } + + // Fall back to client-side rules for private key materials / other formats. + return ClientAEADSeed(key) +} + +// GenKeyPair generates a client "available private key" and the corresponding server public key. +func GenKeyPair() (privateKey, publicKey string, err error) { + pair, err := crypto.GenerateMasterKey() + if err != nil { + return "", "", err + } + availablePrivateKey, err := crypto.SplitPrivateKey(pair.Private) + if err != nil { + return "", "", err + } + return availablePrivateKey, crypto.EncodePoint(pair.Public), nil +} diff --git a/transport/sudoku/kip.go b/transport/sudoku/kip.go new file mode 100644 index 00000000..bfe5e57b --- /dev/null +++ b/transport/sudoku/kip.go @@ -0,0 +1,259 @@ +package sudoku + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "io" + "strings" + "time" + + sudokuobfs "github.com/sagernet/sing-box/transport/sudoku/obfs/sudoku" +) + +const ( + kipMagic = "kip" + + KIPTypeClientHello byte = 0x01 + KIPTypeServerHello byte = 0x02 + + KIPTypeOpenTCP byte = 0x10 + KIPTypeStartMux byte = 0x11 + KIPTypeStartUoT byte = 0x12 + KIPTypeKeepAlive byte = 0x14 +) + +// KIP feature bits are advisory capability flags negotiated during the handshake. +// They represent control-plane message families. +const ( + KIPFeatOpenTCP uint32 = 1 << 0 + KIPFeatMux uint32 = 1 << 1 + KIPFeatUoT uint32 = 1 << 2 + KIPFeatKeepAlive uint32 = 1 << 4 + + KIPFeatAll = KIPFeatOpenTCP | KIPFeatMux | KIPFeatUoT | KIPFeatKeepAlive +) + +const ( + kipHelloUserHashSize = 8 + kipHelloNonceSize = 16 + kipHelloPubSize = 32 + kipMaxPayload = 64 * 1024 +) + +const kipClientHelloTableHintSize = 4 + +var errKIP = errors.New("kip protocol error") + +type KIPMessage struct { + Type byte + Payload []byte +} + +func WriteKIPMessage(w io.Writer, typ byte, payload []byte) error { + if w == nil { + return fmt.Errorf("%w: nil writer", errKIP) + } + if len(payload) > kipMaxPayload { + return fmt.Errorf("%w: payload too large: %d", errKIP, len(payload)) + } + + var hdr [3 + 1 + 2]byte + copy(hdr[:3], []byte(kipMagic)) + hdr[3] = typ + binary.BigEndian.PutUint16(hdr[4:], uint16(len(payload))) + + return writeAllChunks(w, hdr[:], payload) +} + +func ReadKIPMessage(r io.Reader) (*KIPMessage, error) { + if r == nil { + return nil, fmt.Errorf("%w: nil reader", errKIP) + } + var hdr [3 + 1 + 2]byte + if _, err := io.ReadFull(r, hdr[:]); err != nil { + return nil, err + } + if string(hdr[:3]) != kipMagic { + return nil, fmt.Errorf("%w: bad magic", errKIP) + } + typ := hdr[3] + n := int(binary.BigEndian.Uint16(hdr[4:])) + if n < 0 || n > kipMaxPayload { + return nil, fmt.Errorf("%w: invalid payload length: %d", errKIP, n) + } + var payload []byte + if n > 0 { + payload = make([]byte, n) + if _, err := io.ReadFull(r, payload); err != nil { + return nil, err + } + } + return &KIPMessage{Type: typ, Payload: payload}, nil +} + +type KIPClientHello struct { + Timestamp time.Time + UserHash [kipHelloUserHashSize]byte + Nonce [kipHelloNonceSize]byte + ClientPub [kipHelloPubSize]byte + Features uint32 + TableHint uint32 + HasTableHint bool +} + +type KIPServerHello struct { + Nonce [kipHelloNonceSize]byte + ServerPub [kipHelloPubSize]byte + SelectedFeats uint32 +} + +func newKIPClientHello(userHash [kipHelloUserHashSize]byte, nonce [kipHelloNonceSize]byte, clientPub [kipHelloPubSize]byte, feats uint32, tableHint uint32, hasTableHint bool) *KIPClientHello { + return &KIPClientHello{ + Timestamp: time.Now(), + UserHash: userHash, + Nonce: nonce, + ClientPub: clientPub, + Features: feats, + TableHint: tableHint, + HasTableHint: hasTableHint, + } +} + +func kipUserHashFromKey(psk string) [kipHelloUserHashSize]byte { + var out [kipHelloUserHashSize]byte + psk = strings.TrimSpace(psk) + if psk == "" { + return out + } + + // Align with upstream: when the client carries private key material (or even just a public key), + // prefer hashing the raw hex bytes so different split/master keys can be distinguished. + if keyBytes, err := hex.DecodeString(psk); err == nil && len(keyBytes) > 0 { + sum := sha256.Sum256(keyBytes) + copy(out[:], sum[:kipHelloUserHashSize]) + return out + } + + sum := sha256.Sum256([]byte(psk)) + copy(out[:], sum[:kipHelloUserHashSize]) + return out +} + +func KIPUserHashHexFromKey(psk string) string { + uh := kipUserHashFromKey(psk) + return hex.EncodeToString(uh[:]) +} + +func (m *KIPClientHello) EncodePayload() []byte { + var b bytes.Buffer + var tmp [8]byte + binary.BigEndian.PutUint64(tmp[:], uint64(m.Timestamp.Unix())) + b.Write(tmp[:]) + b.Write(m.UserHash[:]) + b.Write(m.Nonce[:]) + b.Write(m.ClientPub[:]) + var f [4]byte + binary.BigEndian.PutUint32(f[:], m.Features) + b.Write(f[:]) + if m.HasTableHint { + var hint [kipClientHelloTableHintSize]byte + binary.BigEndian.PutUint32(hint[:], m.TableHint) + b.Write(hint[:]) + } + return b.Bytes() +} + +func DecodeKIPClientHelloPayload(payload []byte) (*KIPClientHello, error) { + const minLen = 8 + kipHelloUserHashSize + kipHelloNonceSize + kipHelloPubSize + 4 + if len(payload) < minLen { + return nil, fmt.Errorf("%w: client hello too short", errKIP) + } + var h KIPClientHello + ts := int64(binary.BigEndian.Uint64(payload[:8])) + h.Timestamp = time.Unix(ts, 0) + off := 8 + copy(h.UserHash[:], payload[off:off+kipHelloUserHashSize]) + off += kipHelloUserHashSize + copy(h.Nonce[:], payload[off:off+kipHelloNonceSize]) + off += kipHelloNonceSize + copy(h.ClientPub[:], payload[off:off+kipHelloPubSize]) + off += kipHelloPubSize + h.Features = binary.BigEndian.Uint32(payload[off : off+4]) + off += 4 + if len(payload) >= off+kipClientHelloTableHintSize { + h.TableHint = binary.BigEndian.Uint32(payload[off : off+kipClientHelloTableHintSize]) + h.HasTableHint = true + } + return &h, nil +} + +func ResolveClientHelloTable(selected *sudokuobfs.Table, candidates []*sudokuobfs.Table, hello *KIPClientHello) (*sudokuobfs.Table, error) { + if selected == nil { + return nil, fmt.Errorf("nil selected table") + } + if hello == nil || !hello.HasTableHint { + return selected, nil + } + if selected.Hint() == hello.TableHint { + return selected, nil + } + if len(candidates) == 0 { + return nil, fmt.Errorf("no table candidates") + } + + var hinted *sudokuobfs.Table + for _, candidate := range candidates { + if candidate == nil || candidate.Hint() != hello.TableHint { + continue + } + hinted = candidate + break + } + if hinted == nil { + return nil, fmt.Errorf("unknown table hint: %d", hello.TableHint) + } + if hinted != selected && (!hinted.IsASCII || !selected.IsASCII) { + return nil, fmt.Errorf("table hint %d mismatches probed uplink table", hello.TableHint) + } + return hinted, nil +} + +func (m *KIPServerHello) EncodePayload() []byte { + var b bytes.Buffer + b.Write(m.Nonce[:]) + b.Write(m.ServerPub[:]) + var f [4]byte + binary.BigEndian.PutUint32(f[:], m.SelectedFeats) + b.Write(f[:]) + return b.Bytes() +} + +func DecodeKIPServerHelloPayload(payload []byte) (*KIPServerHello, error) { + const want = kipHelloNonceSize + kipHelloPubSize + 4 + if len(payload) != want { + return nil, fmt.Errorf("%w: server hello bad len: %d", errKIP, len(payload)) + } + var h KIPServerHello + off := 0 + copy(h.Nonce[:], payload[off:off+kipHelloNonceSize]) + off += kipHelloNonceSize + copy(h.ServerPub[:], payload[off:off+kipHelloPubSize]) + off += kipHelloPubSize + h.SelectedFeats = binary.BigEndian.Uint32(payload[off : off+4]) + return &h, nil +} + +func writeFull(w io.Writer, b []byte) error { + for len(b) > 0 { + n, err := w.Write(b) + if err != nil { + return err + } + b = b[n:] + } + return nil +} diff --git a/transport/sudoku/multiplex.go b/transport/sudoku/multiplex.go new file mode 100644 index 00000000..4cedebe8 --- /dev/null +++ b/transport/sudoku/multiplex.go @@ -0,0 +1,130 @@ +package sudoku + +import ( + "bytes" + "context" + "fmt" + "net" + "strings" + + "github.com/sagernet/sing-box/transport/sudoku/multiplex" +) + +// StartMultiplexClient upgrades an already-handshaked Sudoku tunnel into a multiplex session. +func StartMultiplexClient(conn net.Conn) (*MultiplexClient, error) { + if conn == nil { + return nil, fmt.Errorf("nil conn") + } + + if err := WriteKIPMessage(conn, KIPTypeStartMux, nil); err != nil { + return nil, fmt.Errorf("write mux start failed: %w", err) + } + + sess, err := multiplex.NewClientSession(conn) + if err != nil { + return nil, fmt.Errorf("start multiplex session failed: %w", err) + } + + return &MultiplexClient{sess: sess}, nil +} + +type MultiplexClient struct { + sess *multiplex.Session +} + +// Dial opens a new logical stream, writes the target address, and returns the stream as net.Conn. +func (c *MultiplexClient) Dial(ctx context.Context, targetAddress string) (net.Conn, error) { + if c == nil || c.sess == nil || c.sess.IsClosed() { + return nil, fmt.Errorf("multiplex session is closed") + } + if strings.TrimSpace(targetAddress) == "" { + return nil, fmt.Errorf("target address cannot be empty") + } + + addrBuf, err := EncodeAddress(targetAddress) + if err != nil { + return nil, fmt.Errorf("encode target address failed: %w", err) + } + + if ctx != nil && ctx.Err() != nil { + return nil, ctx.Err() + } + + stream, err := c.sess.OpenStream(addrBuf) + if err != nil { + return nil, err + } + return stream, nil +} + +func (c *MultiplexClient) Close() error { + if c == nil || c.sess == nil { + return nil + } + return c.sess.Close() +} + +func (c *MultiplexClient) IsClosed() bool { + if c == nil || c.sess == nil { + return true + } + return c.sess.IsClosed() +} + +// AcceptMultiplexServer upgrades a server-side, already-handshaked Sudoku connection into a multiplex session. +func AcceptMultiplexServer(conn net.Conn) (*MultiplexServer, error) { + if conn == nil { + return nil, fmt.Errorf("nil conn") + } + sess, err := multiplex.NewServerSession(conn) + if err != nil { + return nil, err + } + return &MultiplexServer{sess: sess}, nil +} + +// MultiplexServer wraps a multiplex session created from a handshaked Sudoku tunnel connection. +type MultiplexServer struct { + sess *multiplex.Session +} + +func (s *MultiplexServer) AcceptStream() (net.Conn, error) { + if s == nil || s.sess == nil { + return nil, fmt.Errorf("nil session") + } + c, _, err := s.sess.AcceptStream() + return c, err +} + +// AcceptTCP accepts a multiplex stream and returns the target address declared in the open frame. +func (s *MultiplexServer) AcceptTCP() (net.Conn, string, error) { + if s == nil || s.sess == nil { + return nil, "", fmt.Errorf("nil session") + } + stream, payload, err := s.sess.AcceptStream() + if err != nil { + return nil, "", err + } + + target, err := DecodeAddress(bytes.NewReader(payload)) + if err != nil { + _ = stream.Close() + return nil, "", err + } + + return stream, target, nil +} + +func (s *MultiplexServer) Close() error { + if s == nil || s.sess == nil { + return nil + } + return s.sess.Close() +} + +func (s *MultiplexServer) IsClosed() bool { + if s == nil || s.sess == nil { + return true + } + return s.sess.IsClosed() +} diff --git a/transport/sudoku/multiplex/session.go b/transport/sudoku/multiplex/session.go new file mode 100644 index 00000000..4344d8e7 --- /dev/null +++ b/transport/sudoku/multiplex/session.go @@ -0,0 +1,493 @@ +package multiplex + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "sync" + "time" +) + +const ( + frameOpen byte = 0x01 + frameData byte = 0x02 + frameClose byte = 0x03 + frameReset byte = 0x04 +) + +const ( + headerSize = 1 + 4 + 4 + maxFrameSize = 256 * 1024 + maxDataPayload = 32 * 1024 +) + +type acceptEvent struct { + stream *stream + payload []byte +} + +type Session struct { + conn net.Conn + + writeMu sync.Mutex + + streamsMu sync.Mutex + streams map[uint32]*stream + nextID uint32 + + acceptCh chan acceptEvent + + closed chan struct{} + closeOnce sync.Once + closeErr error +} + +func NewClientSession(conn net.Conn) (*Session, error) { + if conn == nil { + return nil, fmt.Errorf("nil conn") + } + s := &Session{ + conn: conn, + streams: make(map[uint32]*stream), + closed: make(chan struct{}), + } + go s.readLoop() + return s, nil +} + +func NewServerSession(conn net.Conn) (*Session, error) { + if conn == nil { + return nil, fmt.Errorf("nil conn") + } + s := &Session{ + conn: conn, + streams: make(map[uint32]*stream), + acceptCh: make(chan acceptEvent, 256), + closed: make(chan struct{}), + } + go s.readLoop() + return s, nil +} + +func (s *Session) IsClosed() bool { + if s == nil { + return true + } + select { + case <-s.closed: + return true + default: + return false + } +} + +func (s *Session) closedErr() error { + s.streamsMu.Lock() + err := s.closeErr + s.streamsMu.Unlock() + if err == nil { + return io.ErrClosedPipe + } + return err +} + +func (s *Session) closeWithError(err error) { + if err == nil { + err = io.ErrClosedPipe + } + s.closeOnce.Do(func() { + s.streamsMu.Lock() + if s.closeErr == nil { + s.closeErr = err + } + streams := make([]*stream, 0, len(s.streams)) + for _, st := range s.streams { + streams = append(streams, st) + } + s.streams = make(map[uint32]*stream) + s.streamsMu.Unlock() + + for _, st := range streams { + st.closeNoSend(err) + } + + close(s.closed) + _ = s.conn.Close() + }) +} + +func (s *Session) Close() error { + if s == nil { + return nil + } + s.closeWithError(io.ErrClosedPipe) + return nil +} + +func (s *Session) registerStream(st *stream) { + s.streamsMu.Lock() + s.streams[st.id] = st + s.streamsMu.Unlock() +} + +func (s *Session) getStream(id uint32) *stream { + s.streamsMu.Lock() + st := s.streams[id] + s.streamsMu.Unlock() + return st +} + +func (s *Session) removeStream(id uint32) { + s.streamsMu.Lock() + delete(s.streams, id) + s.streamsMu.Unlock() +} + +func (s *Session) nextStreamID() uint32 { + s.streamsMu.Lock() + s.nextID++ + id := s.nextID + if id == 0 { + s.nextID++ + id = s.nextID + } + s.streamsMu.Unlock() + return id +} + +func (s *Session) sendFrame(frameType byte, streamID uint32, payload []byte) error { + if s.IsClosed() { + return s.closedErr() + } + if len(payload) > maxFrameSize { + return fmt.Errorf("mux payload too large: %d", len(payload)) + } + + var header [headerSize]byte + header[0] = frameType + binary.BigEndian.PutUint32(header[1:5], streamID) + binary.BigEndian.PutUint32(header[5:9], uint32(len(payload))) + + s.writeMu.Lock() + defer s.writeMu.Unlock() + + if err := writeAllChunks(s.conn, header[:], payload); err != nil { + s.closeWithError(err) + return err + } + return nil +} + +func (s *Session) sendReset(streamID uint32, msg string) { + if msg == "" { + msg = "reset" + } + _ = s.sendFrame(frameReset, streamID, []byte(msg)) + _ = s.sendFrame(frameClose, streamID, nil) +} + +func (s *Session) OpenStream(openPayload []byte) (net.Conn, error) { + if s == nil { + return nil, fmt.Errorf("nil session") + } + if s.IsClosed() { + return nil, s.closedErr() + } + + streamID := s.nextStreamID() + st := newStream(s, streamID) + s.registerStream(st) + + if err := s.sendFrame(frameOpen, streamID, openPayload); err != nil { + st.closeNoSend(err) + s.removeStream(streamID) + return nil, fmt.Errorf("mux open failed: %w", err) + } + return st, nil +} + +func (s *Session) AcceptStream() (net.Conn, []byte, error) { + if s == nil { + return nil, nil, fmt.Errorf("nil session") + } + if s.acceptCh == nil { + return nil, nil, fmt.Errorf("accept is not supported on client sessions") + } + select { + case ev := <-s.acceptCh: + return ev.stream, ev.payload, nil + case <-s.closed: + return nil, nil, s.closedErr() + } +} + +func (s *Session) readLoop() { + var header [headerSize]byte + for { + if _, err := io.ReadFull(s.conn, header[:]); err != nil { + s.closeWithError(err) + return + } + frameType := header[0] + streamID := binary.BigEndian.Uint32(header[1:5]) + n := int(binary.BigEndian.Uint32(header[5:9])) + if n < 0 || n > maxFrameSize { + s.closeWithError(fmt.Errorf("invalid mux frame length: %d", n)) + return + } + + var payload []byte + if n > 0 { + payload = make([]byte, n) + if _, err := io.ReadFull(s.conn, payload); err != nil { + s.closeWithError(err) + return + } + } + + switch frameType { + case frameOpen: + if s.acceptCh == nil { + s.sendReset(streamID, "unexpected open") + continue + } + if streamID == 0 { + s.sendReset(streamID, "invalid stream id") + continue + } + if existing := s.getStream(streamID); existing != nil { + s.sendReset(streamID, "stream already exists") + continue + } + st := newStream(s, streamID) + s.registerStream(st) + go func() { + select { + case s.acceptCh <- acceptEvent{stream: st, payload: payload}: + case <-s.closed: + st.closeNoSend(io.ErrClosedPipe) + s.removeStream(streamID) + } + }() + + case frameData: + st := s.getStream(streamID) + if st == nil { + continue + } + if len(payload) == 0 { + continue + } + st.enqueue(payload) + + case frameClose: + st := s.getStream(streamID) + if st == nil { + continue + } + st.closeNoSend(io.EOF) + s.removeStream(streamID) + + case frameReset: + st := s.getStream(streamID) + if st == nil { + continue + } + msg := trimASCII(payload) + if msg == "" { + msg = "reset" + } + st.closeNoSend(errors.New(msg)) + s.removeStream(streamID) + + default: + s.closeWithError(fmt.Errorf("unknown mux frame type: %d", frameType)) + return + } + } +} + +func trimASCII(b []byte) string { + i := 0 + j := len(b) + for i < j { + c := b[i] + if c != ' ' && c != '\n' && c != '\r' && c != '\t' { + break + } + i++ + } + for j > i { + c := b[j-1] + if c != ' ' && c != '\n' && c != '\r' && c != '\t' { + break + } + j-- + } + if i >= j { + return "" + } + out := make([]byte, j-i) + copy(out, b[i:j]) + return string(out) +} + +type stream struct { + session *Session + id uint32 + + mu sync.Mutex + cond *sync.Cond + closed bool + closeErr error + readBuf []byte + queue [][]byte + + localAddr net.Addr + remoteAddr net.Addr +} + +func newStream(session *Session, id uint32) *stream { + st := &stream{ + session: session, + id: id, + localAddr: &net.TCPAddr{}, + remoteAddr: &net.TCPAddr{}, + } + st.cond = sync.NewCond(&st.mu) + return st +} + +func (c *stream) enqueue(payload []byte) { + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return + } + if len(c.readBuf) == 0 && len(c.queue) == 0 { + c.readBuf = payload + } else { + c.queue = append(c.queue, payload) + } + c.cond.Signal() + c.mu.Unlock() +} + +func (c *stream) closeNoSend(err error) { + if err == nil { + err = io.EOF + } + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return + } + c.closed = true + if c.closeErr == nil { + c.closeErr = err + } + c.cond.Broadcast() + c.mu.Unlock() +} + +func (c *stream) closedErr() error { + c.mu.Lock() + defer c.mu.Unlock() + if c.closeErr == nil { + return io.ErrClosedPipe + } + return c.closeErr +} + +func (c *stream) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + c.mu.Lock() + defer c.mu.Unlock() + + for len(c.readBuf) == 0 && len(c.queue) == 0 && !c.closed { + c.cond.Wait() + } + if len(c.readBuf) == 0 && len(c.queue) > 0 { + c.readBuf = c.queue[0] + c.queue = c.queue[1:] + } + if len(c.readBuf) == 0 && c.closed { + if c.closeErr == nil { + return 0, io.ErrClosedPipe + } + return 0, c.closeErr + } + + n := copy(p, c.readBuf) + c.readBuf = c.readBuf[n:] + return n, nil +} + +func (c *stream) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + if c.session == nil || c.session.IsClosed() { + if c.session != nil { + return 0, c.session.closedErr() + } + return 0, io.ErrClosedPipe + } + + c.mu.Lock() + closed := c.closed + c.mu.Unlock() + if closed { + return 0, c.closedErr() + } + + written := 0 + for len(p) > 0 { + chunk := p + if len(chunk) > maxDataPayload { + chunk = p[:maxDataPayload] + } + if err := c.session.sendFrame(frameData, c.id, chunk); err != nil { + return written, err + } + written += len(chunk) + p = p[len(chunk):] + } + return written, nil +} + +func (c *stream) Close() error { + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return nil + } + c.closed = true + if c.closeErr == nil { + c.closeErr = io.ErrClosedPipe + } + c.cond.Broadcast() + c.mu.Unlock() + + _ = c.session.sendFrame(frameClose, c.id, nil) + c.session.removeStream(c.id) + return nil +} + +func (c *stream) CloseWrite() error { return c.Close() } +func (c *stream) CloseRead() error { return c.Close() } + +func (c *stream) LocalAddr() net.Addr { return c.localAddr } +func (c *stream) RemoteAddr() net.Addr { return c.remoteAddr } + +func (c *stream) SetDeadline(t time.Time) error { + _ = c.SetReadDeadline(t) + _ = c.SetWriteDeadline(t) + return nil +} +func (c *stream) SetReadDeadline(time.Time) error { return nil } +func (c *stream) SetWriteDeadline(time.Time) error { return nil } diff --git a/transport/sudoku/multiplex/write_chunks.go b/transport/sudoku/multiplex/write_chunks.go new file mode 100644 index 00000000..dd381d3a --- /dev/null +++ b/transport/sudoku/multiplex/write_chunks.go @@ -0,0 +1,19 @@ +package multiplex + +import "io" + +func writeAllChunks(w io.Writer, chunks ...[]byte) error { + for _, chunk := range chunks { + for len(chunk) > 0 { + n, err := w.Write(chunk) + if err != nil { + return err + } + if n == 0 { + return io.ErrShortWrite + } + chunk = chunk[n:] + } + } + return nil +} diff --git a/transport/sudoku/obfs/httpmask/auth.go b/transport/sudoku/obfs/httpmask/auth.go new file mode 100644 index 00000000..706a72ad --- /dev/null +++ b/transport/sudoku/obfs/httpmask/auth.go @@ -0,0 +1,161 @@ +package httpmask + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/binary" + "strings" + "time" + + "net/http" +) + +const ( + tunnelAuthHeaderKey = "Authorization" + tunnelAuthHeaderPrefix = "Bearer " + tunnelAuthQueryKey = "auth" +) + +type tunnelAuth struct { + key [32]byte // derived HMAC key + skew time.Duration +} + +func newTunnelAuth(key string, skew time.Duration) *tunnelAuth { + key = strings.TrimSpace(key) + if key == "" { + return nil + } + if skew <= 0 { + skew = 60 * time.Second + } + + // Domain separation: keep this HMAC key independent from other uses of cfg.Key. + h := sha256.New() + _, _ = h.Write([]byte("sudoku-httpmask-auth-v1:")) + _, _ = h.Write([]byte(key)) + + var sum [32]byte + h.Sum(sum[:0]) + + return &tunnelAuth{key: sum, skew: skew} +} + +func (a *tunnelAuth) token(mode TunnelMode, method, path string, now time.Time) string { + if a == nil { + return "" + } + + ts := now.Unix() + sig := a.sign(mode, method, path, ts) + + var buf [8 + 16]byte + binary.BigEndian.PutUint64(buf[:8], uint64(ts)) + copy(buf[8:], sig[:]) + return base64.RawURLEncoding.EncodeToString(buf[:]) +} + +func (a *tunnelAuth) verify(headers map[string]string, mode TunnelMode, method, path string, now time.Time) bool { + if a == nil { + return true + } + if headers == nil { + return false + } + return a.verifyValue(headers["authorization"], mode, method, path, now) +} + +func (a *tunnelAuth) verifyValue(val string, mode TunnelMode, method, path string, now time.Time) bool { + if a == nil { + return true + } + + val = strings.TrimSpace(val) + if val == "" { + return false + } + + // Accept both "Bearer " and raw token forms (for forward proxies / CDNs that may normalize headers). + if len(val) > len(tunnelAuthHeaderPrefix) && strings.EqualFold(val[:len(tunnelAuthHeaderPrefix)], tunnelAuthHeaderPrefix) { + val = strings.TrimSpace(val[len(tunnelAuthHeaderPrefix):]) + } + if val == "" { + return false + } + + raw, err := base64.RawURLEncoding.DecodeString(val) + if err != nil || len(raw) != 8+16 { + return false + } + + ts := int64(binary.BigEndian.Uint64(raw[:8])) + nowTS := now.Unix() + delta := nowTS - ts + if delta < 0 { + delta = -delta + } + if delta > int64(a.skew.Seconds()) { + return false + } + + want := a.sign(mode, method, path, ts) + return subtle.ConstantTimeCompare(raw[8:], want[:]) == 1 +} + +func (a *tunnelAuth) sign(mode TunnelMode, method, path string, ts int64) [16]byte { + method = strings.ToUpper(strings.TrimSpace(method)) + if method == "" { + method = "GET" + } + path = strings.TrimSpace(path) + + var tsBuf [8]byte + binary.BigEndian.PutUint64(tsBuf[:], uint64(ts)) + + mac := hmac.New(sha256.New, a.key[:]) + _, _ = mac.Write([]byte(mode)) + _, _ = mac.Write([]byte{0}) + _, _ = mac.Write([]byte(method)) + _, _ = mac.Write([]byte{0}) + _, _ = mac.Write([]byte(path)) + _, _ = mac.Write([]byte{0}) + _, _ = mac.Write(tsBuf[:]) + + var full [32]byte + mac.Sum(full[:0]) + + var out [16]byte + copy(out[:], full[:16]) + return out +} + +type httpHeaderSetter = http.Header + +func applyTunnelAuthHeader(h httpHeaderSetter, auth *tunnelAuth, mode TunnelMode, method, path string) { + if auth == nil || h == nil { + return + } + token := auth.token(mode, method, path, time.Now()) + if token == "" { + return + } + h.Set(tunnelAuthHeaderKey, tunnelAuthHeaderPrefix+token) +} + +func applyTunnelAuth(req *http.Request, auth *tunnelAuth, mode TunnelMode, method, path string) { + if auth == nil || req == nil { + return + } + token := auth.token(mode, method, path, time.Now()) + if token == "" { + return + } + req.Header.Set(tunnelAuthHeaderKey, tunnelAuthHeaderPrefix+token) + if req.URL != nil { + q := req.URL.Query() + q.Set(tunnelAuthQueryKey, token) + req.URL.RawQuery = q.Encode() + } +} diff --git a/transport/sudoku/obfs/httpmask/early_handshake.go b/transport/sudoku/obfs/httpmask/early_handshake.go new file mode 100644 index 00000000..54158577 --- /dev/null +++ b/transport/sudoku/obfs/httpmask/early_handshake.go @@ -0,0 +1,174 @@ +package httpmask + +import ( + "encoding/base64" + "errors" + "fmt" + "net" + "net/url" + "strings" +) + +const ( + tunnelEarlyDataQueryKey = "ed" + tunnelEarlyDataHeader = "X-Sudoku-Early" +) + +type ClientEarlyHandshake struct { + RequestPayload []byte + HandleResponse func(payload []byte) error + Ready func() bool + WrapConn func(raw net.Conn) (net.Conn, error) +} + +type TunnelServerEarlyHandshake struct { + Prepare func(payload []byte) (*PreparedServerEarlyHandshake, error) +} + +type PreparedServerEarlyHandshake struct { + ResponsePayload []byte + WrapConn func(raw net.Conn) (net.Conn, error) + UserHash string +} + +type earlyHandshakeMeta interface { + HTTPMaskEarlyHandshakeUserHash() string +} + +type earlyHandshakeConn struct { + net.Conn + userHash string +} + +func (c *earlyHandshakeConn) HTTPMaskEarlyHandshakeUserHash() string { + if c == nil { + return "" + } + return c.userHash +} + +func wrapEarlyHandshakeConn(conn net.Conn, userHash string) net.Conn { + if conn == nil { + return nil + } + return &earlyHandshakeConn{Conn: conn, userHash: userHash} +} + +func EarlyHandshakeUserHash(conn net.Conn) (string, bool) { + if conn == nil { + return "", false + } + v, ok := conn.(earlyHandshakeMeta) + if !ok { + return "", false + } + return v.HTTPMaskEarlyHandshakeUserHash(), true +} + +type authorizeResponse struct { + token string + earlyPayload []byte +} + +func isTunnelTokenByte(c byte) bool { + return (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + c == '-' || + c == '_' +} + +func parseAuthorizeResponse(body []byte) (*authorizeResponse, error) { + s := strings.TrimSpace(string(body)) + idx := strings.Index(s, "token=") + if idx < 0 { + return nil, errors.New("missing token") + } + s = s[idx+len("token="):] + if s == "" { + return nil, errors.New("empty token") + } + + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + if isTunnelTokenByte(c) { + b.WriteByte(c) + continue + } + break + } + token := b.String() + if token == "" { + return nil, errors.New("empty token") + } + + out := &authorizeResponse{token: token} + if earlyLine := findAuthorizeField(body, "ed="); earlyLine != "" { + decoded, err := base64.RawURLEncoding.DecodeString(earlyLine) + if err != nil { + return nil, fmt.Errorf("decode early authorize payload failed: %w", err) + } + out.earlyPayload = decoded + } + return out, nil +} + +func findAuthorizeField(body []byte, prefix string) string { + for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, prefix) { + return strings.TrimSpace(strings.TrimPrefix(line, prefix)) + } + } + return "" +} + +func setEarlyDataQuery(rawURL string, payload []byte) (string, error) { + if len(payload) == 0 { + return rawURL, nil + } + u, err := url.Parse(rawURL) + if err != nil { + return "", err + } + q := u.Query() + q.Set(tunnelEarlyDataQueryKey, base64.RawURLEncoding.EncodeToString(payload)) + u.RawQuery = q.Encode() + return u.String(), nil +} + +func parseEarlyDataQuery(u *url.URL) ([]byte, error) { + if u == nil { + return nil, nil + } + val := strings.TrimSpace(u.Query().Get(tunnelEarlyDataQueryKey)) + if val == "" { + return nil, nil + } + return base64.RawURLEncoding.DecodeString(val) +} + +func applyEarlyHandshakeOrUpgrade(raw net.Conn, opts TunnelDialOptions) (net.Conn, error) { + out := raw + if opts.EarlyHandshake != nil && opts.EarlyHandshake.WrapConn != nil && (opts.EarlyHandshake.Ready == nil || opts.EarlyHandshake.Ready()) { + wrapped, err := opts.EarlyHandshake.WrapConn(raw) + if err != nil { + return nil, err + } + if wrapped != nil { + out = wrapped + } + return out, nil + } + if opts.Upgrade != nil { + wrapped, err := opts.Upgrade(raw) + if err != nil { + return nil, err + } + if wrapped != nil { + out = wrapped + } + } + return out, nil +} diff --git a/transport/sudoku/obfs/httpmask/halfpipe.go b/transport/sudoku/obfs/httpmask/halfpipe.go new file mode 100644 index 00000000..afbe0bcc --- /dev/null +++ b/transport/sudoku/obfs/httpmask/halfpipe.go @@ -0,0 +1,229 @@ +package httpmask + +import ( + "io" + "net" + "os" + "sync" + "time" +) + +type pipeDeadline struct { + mu sync.Mutex + timer *time.Timer + cancel chan struct{} +} + +func makePipeDeadline() pipeDeadline { + return pipeDeadline{cancel: make(chan struct{})} +} + +func (d *pipeDeadline) set(t time.Time) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.timer != nil && !d.timer.Stop() { + <-d.cancel + } + d.timer = nil + + closed := isClosedPipeChan(d.cancel) + if t.IsZero() { + if closed { + d.cancel = make(chan struct{}) + } + return + } + + if dur := time.Until(t); dur > 0 { + if closed { + d.cancel = make(chan struct{}) + } + d.timer = time.AfterFunc(dur, func() { + close(d.cancel) + }) + return + } + + if !closed { + close(d.cancel) + } +} + +func (d *pipeDeadline) wait() <-chan struct{} { + d.mu.Lock() + ch := d.cancel + d.mu.Unlock() + return ch +} + +func isClosedPipeChan(ch <-chan struct{}) bool { + select { + case <-ch: + return true + default: + return false + } +} + +type halfPipeAddr struct{} + +func (halfPipeAddr) Network() string { return "pipe" } +func (halfPipeAddr) String() string { return "pipe" } + +type halfPipeConn struct { + wrMu sync.Mutex + + rdRx <-chan []byte + rdTx chan<- int + + wrTx chan<- []byte + wrRx <-chan int + + readOnce sync.Once + writeOnce sync.Once + + localReadDone chan struct{} + localWriteDone chan struct{} + + remoteReadDone <-chan struct{} + remoteWriteDone <-chan struct{} + + readDeadline pipeDeadline + writeDeadline pipeDeadline +} + +func newHalfPipe() (net.Conn, net.Conn) { + cb1 := make(chan []byte) + cb2 := make(chan []byte) + cn1 := make(chan int) + cn2 := make(chan int) + + r1 := make(chan struct{}) + w1 := make(chan struct{}) + r2 := make(chan struct{}) + w2 := make(chan struct{}) + + c1 := &halfPipeConn{ + rdRx: cb1, + rdTx: cn1, + wrTx: cb2, + wrRx: cn2, + + localReadDone: r1, + localWriteDone: w1, + remoteReadDone: r2, + remoteWriteDone: w2, + + readDeadline: makePipeDeadline(), + writeDeadline: makePipeDeadline(), + } + c2 := &halfPipeConn{ + rdRx: cb2, + rdTx: cn2, + wrTx: cb1, + wrRx: cn1, + + localReadDone: r2, + localWriteDone: w2, + remoteReadDone: r1, + remoteWriteDone: w1, + + readDeadline: makePipeDeadline(), + writeDeadline: makePipeDeadline(), + } + return c1, c2 +} + +func (*halfPipeConn) LocalAddr() net.Addr { return halfPipeAddr{} } +func (*halfPipeConn) RemoteAddr() net.Addr { return halfPipeAddr{} } + +func (c *halfPipeConn) Read(p []byte) (int, error) { + switch { + case isClosedPipeChan(c.localReadDone): + return 0, io.ErrClosedPipe + case isClosedPipeChan(c.remoteWriteDone): + return 0, io.EOF + case isClosedPipeChan(c.readDeadline.wait()): + return 0, os.ErrDeadlineExceeded + } + + select { + case b := <-c.rdRx: + n := copy(p, b) + c.rdTx <- n + return n, nil + case <-c.localReadDone: + return 0, io.ErrClosedPipe + case <-c.remoteWriteDone: + return 0, io.EOF + case <-c.readDeadline.wait(): + return 0, os.ErrDeadlineExceeded + } +} + +func (c *halfPipeConn) Write(p []byte) (int, error) { + switch { + case isClosedPipeChan(c.localWriteDone): + return 0, io.ErrClosedPipe + case isClosedPipeChan(c.remoteReadDone): + return 0, io.ErrClosedPipe + case isClosedPipeChan(c.writeDeadline.wait()): + return 0, os.ErrDeadlineExceeded + } + + c.wrMu.Lock() + defer c.wrMu.Unlock() + + var ( + total int + rest = p + ) + for once := true; once || len(rest) > 0; once = false { + select { + case c.wrTx <- rest: + n := <-c.wrRx + rest = rest[n:] + total += n + case <-c.localWriteDone: + return total, io.ErrClosedPipe + case <-c.remoteReadDone: + return total, io.ErrClosedPipe + case <-c.writeDeadline.wait(): + return total, os.ErrDeadlineExceeded + } + } + return total, nil +} + +func (c *halfPipeConn) CloseWrite() error { + c.writeOnce.Do(func() { close(c.localWriteDone) }) + return nil +} + +func (c *halfPipeConn) CloseRead() error { + c.readOnce.Do(func() { close(c.localReadDone) }) + return nil +} + +func (c *halfPipeConn) Close() error { + _ = c.CloseRead() + _ = c.CloseWrite() + return nil +} + +func (c *halfPipeConn) SetDeadline(t time.Time) error { + c.readDeadline.set(t) + c.writeDeadline.set(t) + return nil +} + +func (c *halfPipeConn) SetReadDeadline(t time.Time) error { + c.readDeadline.set(t) + return nil +} + +func (c *halfPipeConn) SetWriteDeadline(t time.Time) error { + c.writeDeadline.set(t) + return nil +} diff --git a/transport/sudoku/obfs/httpmask/masker.go b/transport/sudoku/obfs/httpmask/masker.go new file mode 100644 index 00000000..4736d6ff --- /dev/null +++ b/transport/sudoku/obfs/httpmask/masker.go @@ -0,0 +1,252 @@ +package httpmask + +import ( + "bufio" + "bytes" + "encoding/base64" + "fmt" + "io" + "math/rand" + "net" + "strconv" + "strings" + "sync" + "time" +) + +var ( + userAgents = []string{ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36", + } + accepts = []string{ + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "application/json, text/plain, */*", + "application/octet-stream", + "*/*", + } + acceptLanguages = []string{ + "en-US,en;q=0.9", + "en-GB,en;q=0.9", + "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", + "ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7", + "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7", + } + acceptEncodings = []string{ + "gzip, deflate, br", + "gzip, deflate", + "br, gzip, deflate", + } + paths = []string{ + "/api/v1/upload", + "/data/sync", + "/uploads/raw", + "/api/report", + "/feed/update", + "/v2/events", + "/v1/telemetry", + "/session", + "/stream", + "/ws", + } + contentTypes = []string{ + "application/octet-stream", + "application/x-protobuf", + "application/json", + } +) + +var ( + rngPool = sync.Pool{ + New: func() interface{} { + return rand.New(rand.NewSource(time.Now().UnixNano())) + }, + } + headerBufPool = sync.Pool{ + New: func() interface{} { + b := make([]byte, 0, 1024) + return &b + }, + } +) + +// LooksLikeHTTPRequestStart reports whether peek4 looks like a supported HTTP/1.x request method prefix. +func LooksLikeHTTPRequestStart(peek4 []byte) bool { + if len(peek4) < 4 { + return false + } + // Common methods: "GET ", "POST", "HEAD", "PUT ", "OPTI" (OPTIONS), "PATC" (PATCH), "DELE" (DELETE) + return bytes.Equal(peek4, []byte("GET ")) || + bytes.Equal(peek4, []byte("POST")) || + bytes.Equal(peek4, []byte("HEAD")) || + bytes.Equal(peek4, []byte("PUT ")) || + bytes.Equal(peek4, []byte("OPTI")) || + bytes.Equal(peek4, []byte("PATC")) || + bytes.Equal(peek4, []byte("DELE")) +} + +func trimPortForHost(host string) string { + if host == "" { + return host + } + // Accept "example.com:443" / "1.2.3.4:443" / "[::1]:443" + h, _, err := net.SplitHostPort(host) + if err == nil && h != "" { + return h + } + // If it's not in host:port form, keep as-is. + return host +} + +func appendCommonHeaders(buf []byte, host string, r *rand.Rand) []byte { + ua := userAgents[r.Intn(len(userAgents))] + accept := accepts[r.Intn(len(accepts))] + lang := acceptLanguages[r.Intn(len(acceptLanguages))] + enc := acceptEncodings[r.Intn(len(acceptEncodings))] + + buf = append(buf, "Host: "...) + buf = append(buf, host...) + buf = append(buf, "\r\nUser-Agent: "...) + buf = append(buf, ua...) + buf = append(buf, "\r\nAccept: "...) + buf = append(buf, accept...) + buf = append(buf, "\r\nAccept-Language: "...) + buf = append(buf, lang...) + buf = append(buf, "\r\nAccept-Encoding: "...) + buf = append(buf, enc...) + buf = append(buf, "\r\nConnection: keep-alive\r\n"...) + + // A couple of common cache headers; keep them static for simplicity. + buf = append(buf, "Cache-Control: no-cache\r\nPragma: no-cache\r\n"...) + return buf +} + +// WriteRandomRequestHeader writes a plausible HTTP/1.1 request header as a mask. +func WriteRandomRequestHeader(w io.Writer, host string) error { + return WriteRandomRequestHeaderWithPathRoot(w, host, "") +} + +// WriteRandomRequestHeaderWithPathRoot is like WriteRandomRequestHeader but prefixes all paths with pathRoot. +// pathRoot must be a single segment (e.g. "aabbcc"); invalid inputs are treated as empty (disabled). +func WriteRandomRequestHeaderWithPathRoot(w io.Writer, host string, pathRoot string) error { + // Get RNG from pool + r := rngPool.Get().(*rand.Rand) + defer rngPool.Put(r) + + path := joinPathRoot(pathRoot, paths[r.Intn(len(paths))]) + ctype := contentTypes[r.Intn(len(contentTypes))] + + // Use buffer pool + bufPtr := headerBufPool.Get().(*[]byte) + buf := *bufPtr + buf = buf[:0] + defer func() { + if cap(buf) <= 4096 { + *bufPtr = buf + headerBufPool.Put(bufPtr) + } + }() + + // Weighted template selection. Keep a conservative default (POST w/ Content-Length), + // but occasionally rotate to other realistic templates (e.g. WebSocket upgrade). + switch r.Intn(10) { + case 0, 1: // ~20% WebSocket-like upgrade + hostNoPort := trimPortForHost(host) + var keyBytes [16]byte + for i := 0; i < len(keyBytes); i++ { + keyBytes[i] = byte(r.Intn(256)) + } + wsKey := base64.StdEncoding.EncodeToString(keyBytes[:]) + + buf = append(buf, "GET "...) + buf = append(buf, path...) + buf = append(buf, " HTTP/1.1\r\n"...) + buf = appendCommonHeaders(buf, host, r) + buf = append(buf, "Upgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: "...) + buf = append(buf, wsKey...) + buf = append(buf, "\r\nOrigin: https://"...) + buf = append(buf, hostNoPort...) + buf = append(buf, "\r\n\r\n"...) + default: // ~80% POST upload + // Random Content-Length: 4KB–10MB. Small enough to look plausible, large enough + // to justify long-lived writes on keep-alive connections. + const minCL = int64(4 * 1024) + const maxCL = int64(10 * 1024 * 1024) + contentLength := minCL + r.Int63n(maxCL-minCL+1) + + buf = append(buf, "POST "...) + buf = append(buf, path...) + buf = append(buf, " HTTP/1.1\r\n"...) + buf = appendCommonHeaders(buf, host, r) + buf = append(buf, "Content-Type: "...) + buf = append(buf, ctype...) + buf = append(buf, "\r\nContent-Length: "...) + buf = strconv.AppendInt(buf, contentLength, 10) + // A couple of extra headers seen in real clients. + if r.Intn(2) == 0 { + buf = append(buf, "\r\nX-Requested-With: XMLHttpRequest"...) + } + if r.Intn(3) == 0 { + buf = append(buf, "\r\nReferer: https://"...) + buf = append(buf, trimPortForHost(host)...) + buf = append(buf, "/"...) + } + buf = append(buf, "\r\n\r\n"...) + } + + _, err := w.Write(buf) + return err +} + +// ConsumeHeader 读取并消耗 HTTP 头部,返回消耗的数据和剩余的 reader 数据 +// 如果不是 POST 请求或格式严重错误,返回 error +func ConsumeHeader(r *bufio.Reader) ([]byte, error) { + var consumed bytes.Buffer + + // 1. 读取请求行 + // Use ReadSlice to avoid allocation if line fits in buffer + line, err := r.ReadSlice('\n') + if err != nil { + return nil, err + } + consumed.Write(line) + + // Basic method validation: accept common HTTP/1.x methods used by our masker. + // Keep it strict enough to reject obvious garbage. + switch { + case bytes.HasPrefix(line, []byte("POST ")), + bytes.HasPrefix(line, []byte("GET ")), + bytes.HasPrefix(line, []byte("HEAD ")), + bytes.HasPrefix(line, []byte("PUT ")), + bytes.HasPrefix(line, []byte("DELETE ")), + bytes.HasPrefix(line, []byte("OPTIONS ")), + bytes.HasPrefix(line, []byte("PATCH ")): + default: + return consumed.Bytes(), fmt.Errorf("invalid method or garbage: %s", strings.TrimSpace(string(line))) + } + + // 2. 循环读取头部,直到遇到空行 + for { + line, err = r.ReadSlice('\n') + if err != nil { + return consumed.Bytes(), err + } + consumed.Write(line) + + // Check for empty line (\r\n or \n) + // ReadSlice includes the delimiter + n := len(line) + if n == 2 && line[0] == '\r' && line[1] == '\n' { + return consumed.Bytes(), nil + } + if n == 1 && line[0] == '\n' { + return consumed.Bytes(), nil + } + } +} diff --git a/transport/sudoku/obfs/httpmask/pathroot.go b/transport/sudoku/obfs/httpmask/pathroot.go new file mode 100644 index 00000000..0f5f7017 --- /dev/null +++ b/transport/sudoku/obfs/httpmask/pathroot.go @@ -0,0 +1,52 @@ +package httpmask + +import "strings" + +// normalizePathRoot normalizes the configured path root into "/" form. +// +// It is intentionally strict: only a single path segment is allowed, consisting of +// [A-Za-z0-9_-]. Invalid inputs are treated as empty (disabled). +func normalizePathRoot(root string) string { + root = strings.TrimSpace(root) + root = strings.Trim(root, "/") + if root == "" { + return "" + } + for i := 0; i < len(root); i++ { + c := root[i] + switch { + case c >= 'a' && c <= 'z': + case c >= 'A' && c <= 'Z': + case c >= '0' && c <= '9': + case c == '_' || c == '-': + default: + return "" + } + } + return "/" + root +} + +func joinPathRoot(root, path string) string { + root = normalizePathRoot(root) + if root == "" { + return path + } + if path == "" { + return root + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return root + path +} + +func stripPathRoot(root, fullPath string) (string, bool) { + root = normalizePathRoot(root) + if root == "" { + return fullPath, true + } + if !strings.HasPrefix(fullPath, root+"/") { + return "", false + } + return strings.TrimPrefix(fullPath, root), true +} diff --git a/transport/sudoku/obfs/httpmask/tunnel.go b/transport/sudoku/obfs/httpmask/tunnel.go new file mode 100644 index 00000000..578f0f43 --- /dev/null +++ b/transport/sudoku/obfs/httpmask/tunnel.go @@ -0,0 +1,2442 @@ +package httpmask + +import ( + "bufio" + "bytes" + "context" + crand "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + mrand "math/rand" + "net" + "net/url" + "os" + "strconv" + "strings" + "sync" + "syscall" + "time" + + + "net/http" + "net/http/httputil" +) + +type TLSClientConfig interface { + Client(conn net.Conn) (net.Conn, error) +} + +type TunnelMode string + +const ( + TunnelModeLegacy TunnelMode = "legacy" + TunnelModeStream TunnelMode = "stream" + TunnelModePoll TunnelMode = "poll" + TunnelModeAuto TunnelMode = "auto" + TunnelModeWS TunnelMode = "ws" +) + +func normalizeTunnelMode(mode string) TunnelMode { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "", string(TunnelModeLegacy): + return TunnelModeLegacy + case string(TunnelModeStream): + return TunnelModeStream + case string(TunnelModePoll): + return TunnelModePoll + case string(TunnelModeAuto): + return TunnelModeAuto + case string(TunnelModeWS): + return TunnelModeWS + default: + // Be conservative: unknown => legacy + return TunnelModeLegacy + } +} + +type HandleResult int + +const ( + HandlePassThrough HandleResult = iota + HandleStartTunnel + HandleDone +) + +type TunnelDialOptions struct { + Mode string + TLSConfig TLSClientConfig + HostOverride string + // PathRoot is an optional first-level path prefix for all HTTP tunnel endpoints. + // Example: "aabbcc" => "/aabbcc/session", "/aabbcc/api/v1/upload", ... + PathRoot string + // AuthKey enables short-term HMAC auth for HTTP tunnel requests (anti-probing). + // When set (non-empty), each HTTP request carries an Authorization bearer token derived from AuthKey. + AuthKey string + // EarlyHandshake folds the protocol handshake into the HTTP/WS setup round trip. + // When the server accepts the early payload, DialTunnel returns a conn that is already post-handshake. + // When the server does not echo early data, DialTunnel falls back to Upgrade. + EarlyHandshake *ClientEarlyHandshake + // Upgrade optionally wraps the raw tunnel conn and/or writes a small prelude before DialTunnel returns. + // It is called with the raw tunnel conn; if it returns a non-nil conn, that conn is returned by DialTunnel. + Upgrade func(raw net.Conn) (net.Conn, error) + // Multiplex controls whether the caller should reuse underlying HTTP connections (HTTP/1.1 keep-alive / HTTP/2). + // To reuse across multiple dials, create a TunnelClient per proxy and reuse it. + // Values: "off" disables reuse; "auto"/"on" enables it. + Multiplex string + // DialContext overrides how the HTTP tunnel dials raw TCP/TLS connections. + // It must not be nil; passing nil is a programming error. + DialContext func(ctx context.Context, network, addr string) (net.Conn, error) +} + +type TunnelClientOptions struct { + TLSConfig TLSClientConfig + HostOverride string + DialContext func(ctx context.Context, network, addr string) (net.Conn, error) + MaxIdleConns int +} + +type TunnelClient struct { + transport *http.Transport + target httpClientTarget +} + +func NewTunnelClient(serverAddress string, opts TunnelClientOptions) (*TunnelClient, error) { + maxIdle := opts.MaxIdleConns + if maxIdle <= 0 { + maxIdle = 32 + } + + transport, target, err := buildHTTPTransport(serverAddress, opts.TLSConfig != nil, opts.TLSConfig, opts.HostOverride, opts.DialContext, maxIdle) + if err != nil { + return nil, err + } + + return &TunnelClient{ + transport: transport, + target: target, + }, nil +} + +func (c *TunnelClient) CloseIdleConnections() { + if c == nil || c.transport == nil { + return + } + c.transport.CloseIdleConnections() +} + +func (c *TunnelClient) DialTunnel(ctx context.Context, opts TunnelDialOptions) (net.Conn, error) { + if c == nil || c.transport == nil { + return nil, fmt.Errorf("nil tunnel client") + } + tm := normalizeTunnelMode(opts.Mode) + if tm == TunnelModeLegacy { + 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 { + case TunnelModeStream: + return dialStreamWithClient(ctx, client, c.target, opts) + case TunnelModePoll: + return dialPollWithClient(ctx, client, c.target, opts) + case TunnelModeWS: + return nil, fmt.Errorf("ws mode does not support TunnelClient reuse") + case TunnelModeAuto: + streamCtx, cancelX := context.WithTimeout(ctx, 3*time.Second) + c1, errX := dialStreamWithClient(streamCtx, client, c.target, opts) + cancelX() + if errX == nil { + return c1, nil + } + c2, errP := dialPollWithClient(ctx, client, c.target, opts) + if errP == nil { + return c2, nil + } + return nil, fmt.Errorf("auto tunnel failed: stream: %v; poll: %w", errX, errP) + default: + return dialStreamWithClient(ctx, client, c.target, opts) + } +} + +// DialTunnel establishes a bidirectional stream over HTTP: +// - stream: a single streaming POST (request body uplink, response body downlink) +// - poll: authorize + push/pull polling tunnel (base64 framed) +// - auto: try stream then fall back to poll +// +// The returned net.Conn carries the raw Sudoku stream (no HTTP headers). +func DialTunnel(ctx context.Context, serverAddress string, opts TunnelDialOptions) (net.Conn, error) { + mode := normalizeTunnelMode(opts.Mode) + if mode == TunnelModeLegacy { + return nil, fmt.Errorf("legacy mode does not use http tunnel") + } + + switch mode { + case TunnelModeStream: + return dialStreamFn(ctx, serverAddress, opts) + case TunnelModePoll: + return dialPollFn(ctx, serverAddress, opts) + case TunnelModeWS: + return dialWS(ctx, serverAddress, opts) + case TunnelModeAuto: + // "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. + streamCtx, cancelX := context.WithTimeout(ctx, 3*time.Second) + c, errX := dialStreamFn(streamCtx, serverAddress, opts) + cancelX() + if errX == nil { + return c, nil + } + c, errP := dialPollFn(ctx, serverAddress, opts) + if errP == nil { + return c, nil + } + return nil, fmt.Errorf("auto tunnel failed: stream: %v; poll: %w", errX, errP) + default: + return dialStreamFn(ctx, serverAddress, opts) + } +} + +var ( + dialStreamFn = dialStream + dialPollFn = dialPoll +) + +func canonicalHeaderHost(urlHost, scheme string) string { + host, port, err := net.SplitHostPort(urlHost) + if err != nil { + return urlHost + } + + defaultPort := "" + switch scheme { + case "https": + defaultPort = "443" + case "http": + defaultPort = "80" + } + if defaultPort == "" || port != defaultPort { + return urlHost + } + + // If we strip the port from an IPv6 literal, re-add brackets to keep the Host header valid. + if strings.Contains(host, ":") { + return "[" + host + "]" + } + return host +} + +func parseTunnelToken(body []byte) (string, error) { + resp, err := parseAuthorizeResponse(body) + if err != nil { + return "", err + } + return resp.token, nil +} + +type httpClientTarget struct { + scheme string + urlHost string + headerHost string +} + +func buildHTTPTransport(serverAddress string, tlsEnabled bool, tlsConfig TLSClientConfig, hostOverride string, dialContext func(ctx context.Context, network, addr string) (net.Conn, error), maxIdleConns int) (*http.Transport, httpClientTarget, error) { + if dialContext == nil { + panic("httpmask: DialContext is nil") + } + + scheme, urlHost, dialAddr, _, err := normalizeHTTPDialTarget(serverAddress, tlsEnabled, hostOverride) + if err != nil { + return nil, httpClientTarget{}, err + } + + transport := &http.Transport{ + ForceAttemptHTTP2: scheme == "https", + DisableCompression: true, + MaxIdleConns: maxIdleConns, + MaxIdleConnsPerHost: maxIdleConns, + IdleConnTimeout: 30 * time.Second, + ResponseHeaderTimeout: 20 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + DialContext: func(dialCtx context.Context, network, _ string) (net.Conn, error) { + return dialContext(dialCtx, network, dialAddr) + }, + } + if scheme == "https" { + if tlsConfig == nil { + return nil, httpClientTarget{}, fmt.Errorf("httpmask: TLSConfig is required when TLS is enabled") + } + transport.DialTLSContext = func(dialCtx context.Context, network, _ string) (net.Conn, error) { + conn, err := dialContext(dialCtx, network, dialAddr) + if err != nil { + return nil, err + } + return tlsConfig.Client(conn) + } + } + + return transport, httpClientTarget{ + scheme: scheme, + urlHost: urlHost, + headerHost: canonicalHeaderHost(urlHost, scheme), + }, nil +} + +func newHTTPClient(serverAddress string, opts TunnelDialOptions, maxIdleConns int) (*http.Client, httpClientTarget, error) { + transport, target, err := buildHTTPTransport(serverAddress, opts.TLSConfig != nil, opts.TLSConfig, opts.HostOverride, opts.DialContext, maxIdleConns) + if err != nil { + return nil, httpClientTarget{}, err + } + return &http.Client{Transport: transport}, target, nil +} + +type sessionDialInfo struct { + client *http.Client + pushURL string + pullURL string + finURL string + closeURL string + headerHost string + 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) { + if client == nil { + return nil, fmt.Errorf("nil http client") + } + + auth := newTunnelAuth(opts.AuthKey, 0) + authorizeURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: joinPathRoot(opts.PathRoot, "/session")}).String() + if opts.EarlyHandshake != nil && len(opts.EarlyHandshake.RequestPayload) > 0 { + var err error + authorizeURL, err = setEarlyDataQuery(authorizeURL, opts.EarlyHandshake.RequestPayload) + if err != nil { + return nil, err + } + } + + var bodyBytes []byte + for attempt := 0; ; attempt++ { + 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) + 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 + } + + authResp, err := parseAuthorizeResponse(bodyBytes) + if err != nil { + return nil, fmt.Errorf("%s authorize failed: %q", mode, strings.TrimSpace(string(bodyBytes))) + } + token := authResp.token + if token == "" { + return nil, fmt.Errorf("%s authorize empty token", mode) + } + if opts.EarlyHandshake != nil && len(authResp.earlyPayload) > 0 && opts.EarlyHandshake.HandleResponse != nil { + if err := opts.EarlyHandshake.HandleResponse(authResp.earlyPayload); err != nil { + return nil, err + } + } + + pushURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: joinPathRoot(opts.PathRoot, "/api/v1/upload"), RawQuery: "token=" + url.QueryEscape(token)}).String() + pullURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: joinPathRoot(opts.PathRoot, "/stream"), RawQuery: "token=" + url.QueryEscape(token)}).String() + finURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: joinPathRoot(opts.PathRoot, "/api/v1/upload"), RawQuery: "token=" + url.QueryEscape(token) + "&fin=1"}).String() + closeURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: joinPathRoot(opts.PathRoot, "/api/v1/upload"), RawQuery: "token=" + url.QueryEscape(token) + "&close=1"}).String() + + return &sessionDialInfo{ + client: client, + pushURL: pushURL, + pullURL: pullURL, + finURL: finURL, + closeURL: closeURL, + headerHost: target.headerHost, + auth: auth, + }, nil +} + +func dialSession(ctx context.Context, serverAddress string, opts TunnelDialOptions, mode TunnelMode) (*sessionDialInfo, error) { + client, target, err := newHTTPClient(serverAddress, opts, 32) + if err != nil { + return nil, err + } + return dialSessionWithClient(ctx, client, target, mode, opts) +} + +func bestEffortCloseSession(client *http.Client, closeURL, headerHost string, mode TunnelMode, auth *tunnelAuth) { + if client == nil || closeURL == "" || headerHost == "" { + return + } + + closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(closeCtx, http.MethodPost, closeURL, nil) + if err != nil { + return + } + req.Host = headerHost + applyTunnelHeaders(req.Header, headerHost, mode) + applyTunnelAuth(req, auth, mode, http.MethodPost, "/api/v1/upload") + + resp, err := client.Do(req) + if err != nil || resp == nil { + return + } + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024)) + _ = resp.Body.Close() +} + +func bestEffortCloseWriteSession(client *http.Client, finURL, headerHost string, mode TunnelMode, auth *tunnelAuth) { + if client == nil || finURL == "" || headerHost == "" { + return + } + + closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(closeCtx, http.MethodPost, finURL, nil) + if err != nil { + return + } + req.Host = headerHost + applyTunnelHeaders(req.Header, headerHost, mode) + applyTunnelAuth(req, auth, mode, http.MethodPost, "/api/v1/upload") + + resp, err := client.Do(req) + if err != nil || resp == nil { + return + } + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024)) + _ = resp.Body.Close() +} + +func dialStreamWithClient(ctx context.Context, client *http.Client, target httpClientTarget, opts TunnelDialOptions) (net.Conn, error) { + // "stream" mode uses split-stream to stay CDN-friendly by default. + return dialStreamSplitWithClient(ctx, client, target, opts) +} + +func dialStream(ctx context.Context, serverAddress string, opts TunnelDialOptions) (net.Conn, error) { + // "stream" mode uses split-stream to stay CDN-friendly by default. + return dialStreamSplit(ctx, serverAddress, opts) +} + +type queuedConn struct { + rxc chan []byte + closed chan struct{} + + writeCh chan []byte + // writeClosed is closed by CloseWrite to stop accepting new payloads. + // When closed, Write returns io.ErrClosedPipe, but Read is unaffected. + writeClosed chan struct{} + + mu sync.Mutex + readBuf []byte + closeErr error + localAddr net.Addr + remoteAddr net.Addr +} + +func (c *queuedConn) CloseWrite() error { + if c == nil || c.writeClosed == nil { + return nil + } + c.mu.Lock() + if !isClosedPipeChan(c.writeClosed) { + close(c.writeClosed) + } + c.mu.Unlock() + return nil +} + +func (c *queuedConn) closeWithError(err error) error { + c.mu.Lock() + select { + case <-c.closed: + c.mu.Unlock() + return nil + default: + if err == nil { + err = io.ErrClosedPipe + } + if c.closeErr == nil { + c.closeErr = err + } + close(c.closed) + } + c.mu.Unlock() + return nil +} + +func (c *queuedConn) closedErr() error { + c.mu.Lock() + err := c.closeErr + c.mu.Unlock() + if err == nil { + return io.ErrClosedPipe + } + return err +} + +func (c *queuedConn) Read(b []byte) (n int, err error) { + if len(c.readBuf) == 0 { + select { + case c.readBuf = <-c.rxc: + case <-c.closed: + return 0, c.closedErr() + } + } + n = copy(b, c.readBuf) + c.readBuf = c.readBuf[n:] + return n, nil +} + +func (c *queuedConn) Write(b []byte) (n int, err error) { + if len(b) == 0 { + return 0, nil + } + c.mu.Lock() + select { + case <-c.closed: + c.mu.Unlock() + return 0, c.closedErr() + case <-c.writeClosed: + c.mu.Unlock() + return 0, io.ErrClosedPipe + default: + } + c.mu.Unlock() + + payload := make([]byte, len(b)) + copy(payload, b) + select { + case c.writeCh <- payload: + return len(b), nil + case <-c.closed: + return 0, c.closedErr() + case <-c.writeClosed: + return 0, io.ErrClosedPipe + } +} + +func (c *queuedConn) LocalAddr() net.Addr { return c.localAddr } +func (c *queuedConn) RemoteAddr() net.Addr { return c.remoteAddr } + +func (c *queuedConn) SetDeadline(time.Time) error { return nil } +func (c *queuedConn) SetReadDeadline(time.Time) error { return nil } +func (c *queuedConn) SetWriteDeadline(time.Time) error { return nil } + +type streamSplitConn struct { + queuedConn + + ctx context.Context + cancel context.CancelFunc + + client *http.Client + pushURL string + pullURL string + finURL string + closeURL string + headerHost string + auth *tunnelAuth +} + +func (c *streamSplitConn) closeWithError(err error) error { + _ = c.queuedConn.closeWithError(err) + if c.cancel != nil { + c.cancel() + } + bestEffortCloseSession(c.client, c.closeURL, c.headerHost, TunnelModeStream, c.auth) + return nil +} + +func (c *streamSplitConn) Close() error { return c.closeWithError(io.ErrClosedPipe) } + +func newStreamSplitConnFromInfo(info *sessionDialInfo) *streamSplitConn { + if info == nil { + return nil + } + + connCtx, cancel := context.WithCancel(context.Background()) + c := &streamSplitConn{ + ctx: connCtx, + cancel: cancel, + client: info.client, + pushURL: info.pushURL, + pullURL: info.pullURL, + finURL: info.finURL, + closeURL: info.closeURL, + headerHost: info.headerHost, + auth: info.auth, + queuedConn: queuedConn{ + rxc: make(chan []byte, 256), + closed: make(chan struct{}), + writeCh: make(chan []byte, 256), + writeClosed: make(chan struct{}), + localAddr: &net.TCPAddr{}, + remoteAddr: &net.TCPAddr{}, + }, + } + + go c.pullLoop() + go c.pushLoop() + return c +} + +func dialStreamSplitWithClient(ctx context.Context, client *http.Client, target httpClientTarget, opts TunnelDialOptions) (net.Conn, error) { + info, err := dialSessionWithClient(ctx, client, target, TunnelModeStream, opts) + if err != nil { + return nil, err + } + c := newStreamSplitConnFromInfo(info) + if c == nil { + return nil, fmt.Errorf("failed to build stream split conn") + } + outConn, err := applyEarlyHandshakeOrUpgrade(c, opts) + if err != nil { + _ = c.Close() + return nil, err + } + return outConn, nil +} + +func dialStreamSplit(ctx context.Context, serverAddress string, opts TunnelDialOptions) (net.Conn, error) { + info, err := dialSession(ctx, serverAddress, opts, TunnelModeStream) + if err != nil { + return nil, err + } + c := newStreamSplitConnFromInfo(info) + if c == nil { + return nil, fmt.Errorf("failed to build stream split conn") + } + outConn, err := applyEarlyHandshakeOrUpgrade(c, opts) + if err != nil { + _ = c.Close() + return nil, err + } + return outConn, nil +} + +func (c *streamSplitConn) pullLoop() { + const ( + // requestTimeout must be long enough for continuous high-throughput streams (e.g. mux + large downloads). + // If it is too short, the client cancels the response mid-body and corrupts the byte stream. + requestTimeout = 2 * time.Minute + readChunkSize = 32 * 1024 + idleBackoff = 25 * time.Millisecond + maxDialRetry = 12 + minBackoff = 10 * time.Millisecond + maxBackoff = 250 * time.Millisecond + ) + + var ( + dialRetry int + backoff = minBackoff + ) + buf := make([]byte, readChunkSize) + for { + select { + case <-c.closed: + return + default: + } + + reqCtx, cancel := context.WithTimeout(c.ctx, requestTimeout) + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, c.pullURL, nil) + if err != nil { + cancel() + _ = c.closeWithError(fmt.Errorf("stream pull build request failed: %w", err)) + return + } + req.Host = c.headerHost + applyTunnelHeaders(req.Header, c.headerHost, TunnelModeStream) + applyTunnelAuth(req, c.auth, TunnelModeStream, http.MethodGet, "/stream") + + resp, err := c.client.Do(req) + if err != nil { + cancel() + if (isDialError(err) || isRetryableRequestError(err)) && dialRetry < maxDialRetry { + dialRetry++ + closeIdleConnections(c.client) + select { + case <-time.After(backoff): + case <-c.closed: + return + } + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + continue + } + _ = c.closeWithError(fmt.Errorf("stream pull request failed: %w", err)) + return + } + dialRetry = 0 + backoff = minBackoff + + 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() + cancel() + _ = c.closeWithError(fmt.Errorf("stream pull bad status: %s", resp.Status)) + return + } + + readAny := false + for { + n, rerr := resp.Body.Read(buf) + if n > 0 { + readAny = true + payload := make([]byte, n) + copy(payload, buf[:n]) + select { + case c.rxc <- payload: + case <-c.closed: + _ = resp.Body.Close() + cancel() + return + } + } + if rerr != nil { + _ = resp.Body.Close() + cancel() + if errors.Is(rerr, io.EOF) { + // Long-poll ended; retry. + break + } + // 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 + } + } + cancel() + if !readAny { + // Avoid tight loop if the server replied quickly with an empty body. + select { + case <-time.After(idleBackoff): + case <-c.closed: + return + } + } + } +} + +func (c *streamSplitConn) pushLoop() { + const ( + // Batching is critical for stability under high concurrency: every flush is a new TCP + // 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 + maxDialRetry = 12 + minBackoff = 10 * time.Millisecond + maxBackoff = 250 * time.Millisecond + ) + + var ( + buf bytes.Buffer + timer = time.NewTimer(flushInterval) + ) + defer timer.Stop() + + flush := func() error { + if buf.Len() == 0 { + return nil + } + + payload := buf.Bytes() + reqCtx, cancel := context.WithTimeout(c.ctx, requestTimeout) + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.pushURL, bytes.NewReader(payload)) + if err != nil { + cancel() + 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 + applyTunnelHeaders(req.Header, c.headerHost, TunnelModeStream) + applyTunnelAuth(req, c.auth, TunnelModeStream, http.MethodPost, "/api/v1/upload") + req.Header.Set("Content-Type", "application/octet-stream") + + resp, err := c.client.Do(req) + if err != nil { + cancel() + return err + } + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024)) + _ = resp.Body.Close() + cancel() + if resp.StatusCode != http.StatusOK { + return &httpStatusError{code: resp.StatusCode, status: resp.Status} + } + + buf.Reset() + return nil + } + + flushWithRetry := func() error { + dialRetry := 0 + backoff := minBackoff + for { + if err := flush(); err == nil { + return nil + } else if se := (*httpStatusError)(nil); errors.As(err, &se) && isRetryableStatusCode(se.code) && dialRetry < maxDialRetry { + 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 { + case <-time.After(backoff): + case <-c.closed: + return io.ErrClosedPipe + } + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + continue + } else { + return err + } + } + } + + resetTimer := func() { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(flushInterval) + } + + resetTimer() + + for { + select { + case b, ok := <-c.writeCh: + if !ok { + _ = flushWithRetry() + return + } + if len(b) == 0 { + continue + } + if buf.Len()+len(b) > maxBatchBytes { + if err := flushWithRetry(); err != nil { + _ = c.closeWithError(fmt.Errorf("stream push flush failed: %w", err)) + return + } + resetTimer() + } + _, _ = buf.Write(b) + if buf.Len() >= maxBatchBytes { + if err := flushWithRetry(); err != nil { + _ = c.closeWithError(fmt.Errorf("stream push flush failed: %w", err)) + return + } + resetTimer() + } + case <-timer.C: + if err := flushWithRetry(); err != nil { + _ = c.closeWithError(fmt.Errorf("stream push flush failed: %w", err)) + return + } + resetTimer() + case <-c.writeClosed: + // Drain any already-accepted writes so CloseWrite does not lose data. + for { + select { + case b := <-c.writeCh: + if len(b) == 0 { + continue + } + if buf.Len()+len(b) > maxBatchBytes { + if err := flushWithRetry(); err != nil { + _ = c.closeWithError(fmt.Errorf("stream push flush failed: %w", err)) + return + } + } + _, _ = buf.Write(b) + default: + _ = flushWithRetry() + bestEffortCloseWriteSession(c.client, c.finURL, c.headerHost, TunnelModeStream, c.auth) + return + } + } + case <-c.closed: + _ = flushWithRetry() + return + } + } +} + +type pollConn struct { + queuedConn + + ctx context.Context + cancel context.CancelFunc + + client *http.Client + pushURL string + pullURL string + finURL string + closeURL string + headerHost string + auth *tunnelAuth +} + +func isDialError(err error) bool { + var urlErr *url.Error + if errors.As(err, &urlErr) { + return isDialError(urlErr.Err) + } + var opErr *net.OpError + if errors.As(err, &opErr) { + if opErr.Op == "dial" || opErr.Op == "connect" { + return true + } + } + 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 { + _ = c.queuedConn.closeWithError(err) + if c.cancel != nil { + c.cancel() + } + bestEffortCloseSession(c.client, c.closeURL, c.headerHost, TunnelModePoll, c.auth) + return nil +} + +func (c *pollConn) Close() error { + return c.closeWithError(io.ErrClosedPipe) +} + +func newPollConnFromInfo(info *sessionDialInfo) *pollConn { + if info == nil { + return nil + } + + connCtx, cancel := context.WithCancel(context.Background()) + c := &pollConn{ + ctx: connCtx, + cancel: cancel, + client: info.client, + pushURL: info.pushURL, + pullURL: info.pullURL, + finURL: info.finURL, + closeURL: info.closeURL, + headerHost: info.headerHost, + auth: info.auth, + queuedConn: queuedConn{ + rxc: make(chan []byte, 128), + closed: make(chan struct{}), + writeCh: make(chan []byte, 256), + writeClosed: make(chan struct{}), + localAddr: &net.TCPAddr{}, + remoteAddr: &net.TCPAddr{}, + }, + } + + go c.pullLoop() + go c.pushLoop() + return c +} + +func dialPollWithClient(ctx context.Context, client *http.Client, target httpClientTarget, opts TunnelDialOptions) (net.Conn, error) { + info, err := dialSessionWithClient(ctx, client, target, TunnelModePoll, opts) + if err != nil { + return nil, err + } + c := newPollConnFromInfo(info) + if c == nil { + return nil, fmt.Errorf("failed to build poll conn") + } + outConn, err := applyEarlyHandshakeOrUpgrade(c, opts) + if err != nil { + _ = c.Close() + return nil, err + } + return outConn, nil +} + +func dialPoll(ctx context.Context, serverAddress string, opts TunnelDialOptions) (net.Conn, error) { + info, err := dialSession(ctx, serverAddress, opts, TunnelModePoll) + if err != nil { + return nil, err + } + c := newPollConnFromInfo(info) + if c == nil { + return nil, fmt.Errorf("failed to build poll conn") + } + outConn, err := applyEarlyHandshakeOrUpgrade(c, opts) + if err != nil { + _ = c.Close() + return nil, err + } + return outConn, nil +} + +func (c *pollConn) pullLoop() { + const ( + maxDialRetry = 12 + minBackoff = 10 * time.Millisecond + maxBackoff = 250 * time.Millisecond + ) + var ( + dialRetry int + backoff = minBackoff + ) + for { + select { + case <-c.closed: + return + default: + } + + reqCtx, cancel := context.WithTimeout(c.ctx, 30*time.Second) + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, c.pullURL, nil) + if err != nil { + cancel() + _ = c.Close() + return + } + req.Host = c.headerHost + applyTunnelHeaders(req.Header, c.headerHost, TunnelModePoll) + applyTunnelAuth(req, c.auth, TunnelModePoll, http.MethodGet, "/stream") + + resp, err := c.client.Do(req) + if err != nil { + cancel() + if (isDialError(err) || isRetryableRequestError(err)) && dialRetry < maxDialRetry { + dialRetry++ + closeIdleConnections(c.client) + select { + case <-time.After(backoff): + case <-c.closed: + return + } + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + continue + } + _ = c.closeWithError(fmt.Errorf("poll pull request failed: %w", err)) + return + } + dialRetry = 0 + backoff = minBackoff + + 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() + cancel() + _ = c.closeWithError(fmt.Errorf("poll pull bad status: %s", resp.Status)) + return + } + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + payload, err := base64.StdEncoding.DecodeString(line) + if err != nil { + _ = resp.Body.Close() + _ = c.closeWithError(fmt.Errorf("poll pull decode failed: %w", err)) + return + } + select { + case c.rxc <- payload: + case <-c.closed: + _ = resp.Body.Close() + return + } + } + _ = resp.Body.Close() + cancel() + 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)) + return + } + } +} + +func (c *pollConn) pushLoop() { + const ( + maxBatchBytes = 512 * 1024 + flushInterval = 50 * time.Millisecond + maxLineRawBytes = 16 * 1024 + maxDialRetry = 12 + minBackoff = 10 * time.Millisecond + maxBackoff = 250 * time.Millisecond + ) + + var ( + buf bytes.Buffer + pendingRaw int + timer = time.NewTimer(flushInterval) + ) + defer timer.Stop() + + flush := func() error { + if buf.Len() == 0 { + return nil + } + + payload := buf.Bytes() + reqCtx, cancel := context.WithTimeout(c.ctx, 20*time.Second) + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.pushURL, bytes.NewReader(payload)) + if err != nil { + cancel() + return err + } + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(payload)), nil + } + req.Host = c.headerHost + applyTunnelHeaders(req.Header, c.headerHost, TunnelModePoll) + applyTunnelAuth(req, c.auth, TunnelModePoll, http.MethodPost, "/api/v1/upload") + req.Header.Set("Content-Type", "text/plain") + + resp, err := c.client.Do(req) + if err != nil { + cancel() + return err + } + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024)) + _ = resp.Body.Close() + cancel() + if resp.StatusCode != http.StatusOK { + return &httpStatusError{code: resp.StatusCode, status: resp.Status} + } + + buf.Reset() + pendingRaw = 0 + return nil + } + + flushWithRetry := func() error { + dialRetry := 0 + backoff := minBackoff + for { + if err := flush(); err == nil { + return nil + } else if se := (*httpStatusError)(nil); errors.As(err, &se) && isRetryableStatusCode(se.code) && dialRetry < maxDialRetry { + 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 { + case <-time.After(backoff): + case <-c.closed: + return c.closedErr() + } + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + continue + } else { + return err + } + } + } + + resetTimer := func() { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(flushInterval) + } + + resetTimer() + + for { + select { + case b, ok := <-c.writeCh: + if !ok { + _ = flushWithRetry() + return + } + if len(b) == 0 { + continue + } + + // Split large writes into multiple base64 lines to cap per-line size. + for len(b) > 0 { + chunk := b + if len(chunk) > maxLineRawBytes { + chunk = b[:maxLineRawBytes] + } + b = b[len(chunk):] + + encLen := base64.StdEncoding.EncodedLen(len(chunk)) + if pendingRaw+len(chunk) > maxBatchBytes || buf.Len()+encLen+1 > maxBatchBytes*2 { + if err := flushWithRetry(); err != nil { + _ = c.closeWithError(fmt.Errorf("poll push flush failed: %w", err)) + return + } + } + + tmp := make([]byte, base64.StdEncoding.EncodedLen(len(chunk))) + base64.StdEncoding.Encode(tmp, chunk) + buf.Write(tmp) + buf.WriteByte('\n') + pendingRaw += len(chunk) + } + + if pendingRaw >= maxBatchBytes { + if err := flushWithRetry(); err != nil { + _ = c.closeWithError(fmt.Errorf("poll push flush failed: %w", err)) + return + } + resetTimer() + } + case <-timer.C: + if err := flushWithRetry(); err != nil { + _ = c.closeWithError(fmt.Errorf("poll push flush failed: %w", err)) + return + } + resetTimer() + case <-c.writeClosed: + // Drain any already-accepted writes so CloseWrite does not lose data. + for { + select { + case b := <-c.writeCh: + if len(b) == 0 { + continue + } + for len(b) > 0 { + chunk := b + if len(chunk) > maxLineRawBytes { + chunk = b[:maxLineRawBytes] + } + b = b[len(chunk):] + + encLen := base64.StdEncoding.EncodedLen(len(chunk)) + if pendingRaw+len(chunk) > maxBatchBytes || buf.Len()+encLen+1 > maxBatchBytes*2 { + if err := flushWithRetry(); err != nil { + _ = c.closeWithError(fmt.Errorf("poll push flush failed: %w", err)) + return + } + } + + tmp := make([]byte, base64.StdEncoding.EncodedLen(len(chunk))) + base64.StdEncoding.Encode(tmp, chunk) + buf.Write(tmp) + buf.WriteByte('\n') + pendingRaw += len(chunk) + } + default: + _ = flushWithRetry() + bestEffortCloseWriteSession(c.client, c.finURL, c.headerHost, TunnelModePoll, c.auth) + return + } + } + case <-c.closed: + _ = flushWithRetry() + return + } + } +} + +func normalizeHTTPDialTarget(serverAddress string, tlsEnabled bool, hostOverride string) (scheme, urlHost, dialAddr, serverName string, err error) { + host, port, err := net.SplitHostPort(serverAddress) + if err != nil { + return "", "", "", "", fmt.Errorf("invalid server address %q: %w", serverAddress, err) + } + + 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) + } + + if tlsEnabled { + scheme = "https" + } else { + scheme = "http" + } + + dialAddr = net.JoinHostPort(host, port) + return scheme, urlHost, dialAddr, trimPortForHost(serverName), nil +} + +func applyTunnelHeaders(h http.Header, host string, mode TunnelMode) { + 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("Connection", "keep-alive") + h.Set("Host", host) + h.Set("X-Sudoku-Tunnel", string(mode)) + h.Set("X-Sudoku-Version", "1") +} + +type TunnelServerOptions struct { + Mode string + // PathRoot is an optional first-level path prefix for all HTTP tunnel endpoints. + // Example: "aabbcc" => "/aabbcc/session", "/aabbcc/api/v1/upload", ... + PathRoot string + // AuthKey enables short-term HMAC auth for HTTP tunnel requests (anti-probing). + // When set (non-empty), the server requires each request to carry a valid Authorization bearer token. + AuthKey string + // AuthSkew controls allowed clock skew / replay window for AuthKey. 0 uses a conservative default. + AuthSkew time.Duration + // PassThroughOnReject controls how the server handles "recognized but rejected" tunnel requests + // (e.g., wrong mode / wrong path / invalid token). When true, the request bytes are replayed back + // to the caller as HandlePassThrough to allow higher-level fallback handling. + PassThroughOnReject bool + // PullReadTimeout controls how long the server long-poll waits for tunnel downlink data before replying with a keepalive newline. + PullReadTimeout time.Duration + // SessionTTL is a best-effort TTL to prevent leaked sessions. 0 uses a conservative default. + SessionTTL time.Duration + // EarlyHandshake optionally folds the protocol handshake into the initial HTTP/WS round trip. + EarlyHandshake *TunnelServerEarlyHandshake +} + +type TunnelServer struct { + mode TunnelMode + pathRoot string + passThroughOnReject bool + auth *tunnelAuth + + pullReadTimeout time.Duration + sessionTTL time.Duration + earlyHandshake *TunnelServerEarlyHandshake + + mu sync.Mutex + sessions map[string]*tunnelSession +} + +type tunnelSession struct { + conn net.Conn + lastActive time.Time +} + +func NewTunnelServer(opts TunnelServerOptions) *TunnelServer { + mode := normalizeTunnelMode(opts.Mode) + if mode == TunnelModeLegacy { + // Server-side "legacy" means: don't accept stream/poll tunnels; only passthrough. + } + pathRoot := normalizePathRoot(opts.PathRoot) + auth := newTunnelAuth(opts.AuthKey, opts.AuthSkew) + timeout := opts.PullReadTimeout + if timeout <= 0 { + timeout = 10 * time.Second + } + ttl := opts.SessionTTL + if ttl <= 0 { + ttl = 2 * time.Minute + } + return &TunnelServer{ + mode: mode, + pathRoot: pathRoot, + auth: auth, + passThroughOnReject: opts.PassThroughOnReject, + pullReadTimeout: timeout, + sessionTTL: ttl, + earlyHandshake: opts.EarlyHandshake, + sessions: make(map[string]*tunnelSession), + } +} + +// HandleConn inspects rawConn. If it is an HTTP tunnel request (X-Sudoku-Tunnel header), it is handled here and: +// - returns HandleStartTunnel + a net.Conn that carries the raw Sudoku stream (stream mode or poll session pipe) +// - or returns HandleDone if the HTTP request is a poll control request (push/pull) and no Sudoku handshake should run on this TCP conn +// +// If it is not an HTTP tunnel request (or server mode is legacy), it returns HandlePassThrough with a conn that replays any pre-read bytes. +func (s *TunnelServer) HandleConn(rawConn net.Conn) (HandleResult, net.Conn, error) { + if rawConn == nil { + return HandleDone, nil, errors.New("nil conn") + } + + // Small header read deadline to avoid stalling Accept loops. The actual Sudoku handshake has its own deadlines. + _ = rawConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + var first [4]byte + n, err := io.ReadFull(rawConn, first[:]) + if err != nil { + _ = rawConn.SetReadDeadline(time.Time{}) + // Even if short-read, preserve bytes for downstream handlers. + if n > 0 { + return HandlePassThrough, newPreBufferedConn(rawConn, first[:n]), nil + } + return HandleDone, nil, err + } + pc := newPreBufferedConn(rawConn, first[:]) + br := bufio.NewReader(pc) + + if !LooksLikeHTTPRequestStart(first[:]) { + _ = rawConn.SetReadDeadline(time.Time{}) + return HandlePassThrough, pc, nil + } + + req, headerBytes, buffered, err := readHTTPHeader(br) + _ = rawConn.SetReadDeadline(time.Time{}) + if err != nil { + // Not a valid HTTP request; hand it back to the legacy path with replay. + prefix := make([]byte, 0, len(first)+len(headerBytes)+len(buffered)) + if len(headerBytes) == 0 || !bytes.HasPrefix(headerBytes, first[:]) { + prefix = append(prefix, first[:]...) + } + prefix = append(prefix, headerBytes...) + prefix = append(prefix, buffered...) + return HandlePassThrough, newPreBufferedConn(rawConn, prefix), nil + } + + tunnelHeader := strings.ToLower(strings.TrimSpace(req.headers["x-sudoku-tunnel"])) + if tunnelHeader == "" { + // Some CDNs / forward proxies may strip unknown headers. When AuthKey is enabled, we can + // safely infer the intended tunnel mode by verifying the Authorization token against + // both stream/poll modes and picking the one that matches. + if s.auth != nil { + u, err := url.ParseRequestURI(req.target) + if err == nil { + path, ok := stripPathRoot(s.pathRoot, u.Path) + if ok && s.isAllowedBasePath(path) { + authVal := req.headers["authorization"] + if authVal == "" { + authVal = u.Query().Get(tunnelAuthQueryKey) + } + streamOK := s.auth.verifyValue(authVal, TunnelModeStream, req.method, path, time.Now()) + pollOK := s.auth.verifyValue(authVal, TunnelModePoll, req.method, path, time.Now()) + switch { + case streamOK && !pollOK: + tunnelHeader = string(TunnelModeStream) + case pollOK && !streamOK: + tunnelHeader = string(TunnelModePoll) + } + } + } + } + + if tunnelHeader == "" { + // Not our tunnel; replay full bytes to legacy handler. + prefix := make([]byte, 0, len(headerBytes)+len(buffered)) + prefix = append(prefix, headerBytes...) + prefix = append(prefix, buffered...) + return HandlePassThrough, newPreBufferedConn(rawConn, prefix), nil + } + } + + reject := func() (HandleResult, net.Conn, error) { + prefix := make([]byte, 0, len(headerBytes)+len(buffered)) + prefix = append(prefix, headerBytes...) + prefix = append(prefix, buffered...) + return HandlePassThrough, newRejectedPreBufferedConn(rawConn, prefix), nil + } + + if s.mode == TunnelModeLegacy { + if s.passThroughOnReject { + return reject() + } + _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + switch TunnelMode(tunnelHeader) { + case TunnelModeStream: + if s.mode != TunnelModeStream && s.mode != TunnelModeAuto { + if s.passThroughOnReject { + return reject() + } + _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") + _ = rawConn.Close() + return HandleDone, nil, nil + } + return s.handleStream(rawConn, req, headerBytes, buffered) + case TunnelModePoll: + if s.mode != TunnelModePoll && s.mode != TunnelModeAuto { + if s.passThroughOnReject { + return reject() + } + _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") + _ = rawConn.Close() + return HandleDone, nil, nil + } + 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: + if s.passThroughOnReject { + return reject() + } + _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") + _ = rawConn.Close() + return HandleDone, nil, nil + } +} + +type httpRequestHeader struct { + method string + target string // path + query + proto string + headers map[string]string // lower-case keys +} + +func readHTTPHeader(r *bufio.Reader) (*httpRequestHeader, []byte, []byte, error) { + const maxHeaderBytes = 32 * 1024 + + var consumed bytes.Buffer + readLine := func() ([]byte, error) { + line, err := r.ReadSlice('\n') + if len(line) > 0 { + if consumed.Len()+len(line) > maxHeaderBytes { + return line, fmt.Errorf("http header too large") + } + consumed.Write(line) + } + return line, err + } + + // Request line + line, err := readLine() + if err != nil { + return nil, consumed.Bytes(), readAllBuffered(r), err + } + lineStr := strings.TrimRight(string(line), "\r\n") + parts := strings.SplitN(lineStr, " ", 3) + if len(parts) != 3 { + return nil, consumed.Bytes(), readAllBuffered(r), fmt.Errorf("invalid request line") + } + req := &httpRequestHeader{ + method: parts[0], + target: parts[1], + proto: parts[2], + headers: make(map[string]string), + } + + // Headers + for { + line, err = readLine() + if err != nil { + return nil, consumed.Bytes(), readAllBuffered(r), err + } + trimmed := strings.TrimRight(string(line), "\r\n") + if trimmed == "" { + break + } + k, v, ok := strings.Cut(trimmed, ":") + if !ok { + continue + } + k = strings.ToLower(strings.TrimSpace(k)) + v = strings.TrimSpace(v) + if k == "" { + continue + } + // Keep the first value; we only care about a small set. + if _, exists := req.headers[k]; !exists { + req.headers[k] = v + } + } + + return req, consumed.Bytes(), readAllBuffered(r), nil +} + +func readAllBuffered(r *bufio.Reader) []byte { + n := r.Buffered() + if n <= 0 { + return nil + } + b, err := r.Peek(n) + if err != nil { + return nil + } + out := make([]byte, n) + copy(out, b) + return out +} + +type preBufferedConn struct { + net.Conn + buf []byte + recorded []byte + rejected bool +} + +func (p *preBufferedConn) CloseWrite() error { + if p == nil || p.Conn == 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 || p.Conn == nil { + return nil + } + if cr, ok := p.Conn.(interface{ CloseRead() error }); ok { + return cr.CloseRead() + } + return nil +} + +func newPreBufferedConn(conn net.Conn, pre []byte) *preBufferedConn { + cpy := make([]byte, len(pre)) + copy(cpy, pre) + return &preBufferedConn{Conn: conn, buf: cpy, recorded: cpy} +} + +func newRejectedPreBufferedConn(conn net.Conn, pre []byte) *preBufferedConn { + c := newPreBufferedConn(conn, pre) + c.rejected = true + return c +} + +func (p *preBufferedConn) IsHTTPMaskRejected() bool { return p.rejected } + +func (p *preBufferedConn) GetBufferedAndRecorded() []byte { + if len(p.recorded) == 0 { + return nil + } + out := make([]byte, len(p.recorded)) + copy(out, p.recorded) + return out +} + +func (p *preBufferedConn) Read(b []byte) (int, error) { + if len(p.buf) > 0 { + n := copy(b, p.buf) + p.buf = p.buf[n:] + return n, nil + } + return p.Conn.Read(b) +} + +type bodyConn struct { + net.Conn + reader io.Reader + writer io.WriteCloser + tail io.Writer + flush func() error +} + +func (c *bodyConn) Read(p []byte) (int, error) { return c.reader.Read(p) } +func (c *bodyConn) Write(p []byte) (int, error) { + n, err := c.writer.Write(p) + if c.flush != nil { + _ = c.flush() + } + return n, err +} + +func (c *bodyConn) Close() error { + var firstErr error + if c.writer != nil { + if err := c.writer.Close(); err != nil && firstErr == nil { + firstErr = err + } + // NewChunkedWriter does not write the final CRLF. Ensure a clean terminator. + if c.tail != nil { + _, _ = c.tail.Write([]byte("\r\n")) + } else { + _, _ = c.Conn.Write([]byte("\r\n")) + } + if c.flush != nil { + _ = c.flush() + } + } + if err := c.Conn.Close(); err != nil && firstErr == nil { + firstErr = err + } + return firstErr +} + +func (s *TunnelServer) handleStream(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") + } + + // Only accept plausible paths to reduce accidental exposure. + path, ok := stripPathRoot(s.pathRoot, u.Path) + if !ok || !s.isAllowedBasePath(path) { + return rejectOrReply(http.StatusNotFound, "not found") + } + authVal := req.headers["authorization"] + if authVal == "" { + authVal = u.Query().Get(tunnelAuthQueryKey) + } + if !s.auth.verifyValue(authVal, TunnelModeStream, req.method, path, time.Now()) { + return rejectOrReply(http.StatusNotFound, "not found") + } + + token := u.Query().Get("token") + closeFlag := u.Query().Get("close") == "1" + finFlag := u.Query().Get("fin") == "1" + + switch strings.ToUpper(req.method) { + case http.MethodGet: + if token == "" && path == "/session" { + earlyPayload, err := parseEarlyDataQuery(u) + if err != nil { + return rejectOrReply(http.StatusBadRequest, "bad request") + } + return s.sessionAuthorize(rawConn, earlyPayload) + } + // Stream split-session: GET /stream?token=... => downlink poll. + if token != "" && path == "/stream" { + if s.passThroughOnReject && !s.sessionHas(token) { + return rejectOrReply(http.StatusNotFound, "not found") + } + return s.streamPull(rawConn, token) + } + return rejectOrReply(http.StatusBadRequest, "bad request") + + case http.MethodPost: + // Stream split-session: POST /api/v1/upload?token=... => uplink push. + if token != "" && path == "/api/v1/upload" { + if s.passThroughOnReject && !s.sessionHas(token) { + return rejectOrReply(http.StatusNotFound, "not found") + } + if closeFlag { + s.sessionClose(token) + _ = writeSimpleHTTPResponse(rawConn, http.StatusOK, "") + _ = rawConn.Close() + return HandleDone, nil, nil + } + if finFlag { + s.sessionCloseWrite(token) + _ = writeSimpleHTTPResponse(rawConn, http.StatusOK, "") + _ = rawConn.Close() + return HandleDone, nil, nil + } + bodyReader, err := newRequestBodyReader(newPreBufferedConn(rawConn, buffered), req.headers) + if err != nil { + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } + return s.streamPush(rawConn, token, bodyReader) + } + + // Stream-one: single full-duplex POST. + if err := writeTunnelResponseHeader(rawConn); err != nil { + _ = rawConn.Close() + return HandleDone, nil, err + } + + bodyReader, err := newRequestBodyReader(newPreBufferedConn(rawConn, buffered), req.headers) + if err != nil { + _ = rawConn.Close() + return HandleDone, nil, err + } + + bw := bufio.NewWriterSize(rawConn, 32*1024) + chunked := httputil.NewChunkedWriter(bw) + stream := &bodyConn{ + Conn: rawConn, + reader: bodyReader, + writer: chunked, + tail: bw, + flush: bw.Flush, + } + return HandleStartTunnel, stream, nil + + default: + return rejectOrReply(http.StatusBadRequest, "bad request") + } +} + +func (s *TunnelServer) isAllowedBasePath(path string) bool { + for _, p := range paths { + if path == p { + return true + } + } + return false +} + +func newRequestBodyReader(conn net.Conn, headers map[string]string) (io.Reader, error) { + br := bufio.NewReaderSize(conn, 32*1024) + + te := strings.ToLower(headers["transfer-encoding"]) + if strings.Contains(te, "chunked") { + return httputil.NewChunkedReader(br), nil + } + if clStr := headers["content-length"]; clStr != "" { + n, err := strconv.ParseInt(strings.TrimSpace(clStr), 10, 64) + if err != nil || n < 0 { + return nil, fmt.Errorf("invalid content-length") + } + return io.LimitReader(br, n), nil + } + return br, nil +} + +func writeTunnelResponseHeader(w io.Writer) error { + _, err := io.WriteString(w, + "HTTP/1.1 200 OK\r\n"+ + "Content-Type: application/octet-stream\r\n"+ + "Transfer-Encoding: chunked\r\n"+ + "Cache-Control: no-store\r\n"+ + "Pragma: no-cache\r\n"+ + "Connection: keep-alive\r\n"+ + "X-Accel-Buffering: no\r\n"+ + "\r\n") + return err +} + +func writeSimpleHTTPResponse(w io.Writer, code int, body string) error { + if body == "" { + body = http.StatusText(code) + } + body = strings.TrimRight(body, "\r\n") + _, err := io.WriteString(w, + fmt.Sprintf("HTTP/1.1 %d %s\r\nContent-Type: text/plain\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", + code, http.StatusText(code), len(body), body)) + return err +} + +func writeTokenHTTPResponse(w io.Writer, token string) error { + token = strings.TrimRight(token, "\r\n") + return writeTokenHTTPResponseWithEarlyData(w, token, nil) +} + +func writeTokenHTTPResponseWithEarlyData(w io.Writer, token string, earlyPayload []byte) error { + token = strings.TrimRight(token, "\r\n") + body := "token=" + token + if len(earlyPayload) > 0 { + body += "\ned=" + base64.RawURLEncoding.EncodeToString(earlyPayload) + } + _, err := io.WriteString(w, + fmt.Sprintf("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nCache-Control: no-store\r\nPragma: no-cache\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", + len(body), body)) + return err +} + +func (s *TunnelServer) handlePoll(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 || !s.isAllowedBasePath(path) { + return rejectOrReply(http.StatusNotFound, "not found") + } + authVal := req.headers["authorization"] + if authVal == "" { + authVal = u.Query().Get(tunnelAuthQueryKey) + } + if !s.auth.verifyValue(authVal, TunnelModePoll, req.method, path, time.Now()) { + return rejectOrReply(http.StatusNotFound, "not found") + } + + token := u.Query().Get("token") + closeFlag := u.Query().Get("close") == "1" + finFlag := u.Query().Get("fin") == "1" + switch strings.ToUpper(req.method) { + case http.MethodGet: + if token == "" && path == "/session" { + earlyPayload, err := parseEarlyDataQuery(u) + if err != nil { + return rejectOrReply(http.StatusBadRequest, "bad request") + } + return s.sessionAuthorize(rawConn, earlyPayload) + } + if token != "" && path == "/stream" { + if s.passThroughOnReject && !s.sessionHas(token) { + return rejectOrReply(http.StatusNotFound, "not found") + } + return s.pollPull(rawConn, token) + } + return rejectOrReply(http.StatusBadRequest, "bad request") + case http.MethodPost: + if token == "" || path != "/api/v1/upload" { + return rejectOrReply(http.StatusBadRequest, "bad request") + } + if s.passThroughOnReject && !s.sessionHas(token) { + return rejectOrReply(http.StatusNotFound, "not found") + } + if closeFlag { + s.sessionClose(token) + _ = writeSimpleHTTPResponse(rawConn, http.StatusOK, "") + _ = rawConn.Close() + return HandleDone, nil, nil + } + if finFlag { + s.sessionCloseWrite(token) + _ = writeSimpleHTTPResponse(rawConn, http.StatusOK, "") + _ = rawConn.Close() + return HandleDone, nil, nil + } + bodyReader, err := newRequestBodyReader(newPreBufferedConn(rawConn, buffered), req.headers) + if err != nil { + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } + return s.pollPush(rawConn, token, bodyReader) + default: + return rejectOrReply(http.StatusBadRequest, "bad request") + } +} + +func (s *TunnelServer) sessionAuthorize(rawConn net.Conn, earlyPayload []byte) (HandleResult, net.Conn, error) { + token, err := newSessionToken() + if err != nil { + _ = writeSimpleHTTPResponse(rawConn, http.StatusInternalServerError, "internal error") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + c1, c2 := newHalfPipe() + outConn := net.Conn(c1) + var responsePayload []byte + var userHash string + if len(earlyPayload) > 0 && s.earlyHandshake != nil && s.earlyHandshake.Prepare != nil { + prepared, err := s.earlyHandshake.Prepare(earlyPayload) + if err != nil { + _ = c1.Close() + _ = c2.Close() + if s.passThroughOnReject { + return HandlePassThrough, newRejectedPreBufferedConn(rawConn, nil), nil + } + _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") + _ = rawConn.Close() + return HandleDone, nil, nil + } + responsePayload = prepared.ResponsePayload + userHash = prepared.UserHash + if prepared.WrapConn != nil { + wrapped, err := prepared.WrapConn(c1) + if err != nil { + _ = c1.Close() + _ = c2.Close() + _ = writeSimpleHTTPResponse(rawConn, http.StatusInternalServerError, "internal error") + _ = rawConn.Close() + return HandleDone, nil, nil + } + if wrapped != nil { + outConn = wrapEarlyHandshakeConn(wrapped, userHash) + } + } + } + + s.mu.Lock() + s.sessions[token] = &tunnelSession{conn: c2, lastActive: time.Now()} + s.mu.Unlock() + + go s.reapLater(token) + + _ = writeTokenHTTPResponseWithEarlyData(rawConn, token, responsePayload) + _ = rawConn.Close() + return HandleStartTunnel, outConn, nil +} + +func newSessionToken() (string, error) { + var b [16]byte + if _, err := crand.Read(b[:]); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b[:]), nil +} + +func (s *TunnelServer) reapLater(token string) { + ttl := s.sessionTTL + if ttl <= 0 { + return + } + + timer := time.NewTimer(ttl) + defer timer.Stop() + + for { + <-timer.C + + s.mu.Lock() + sess, ok := s.sessions[token] + if !ok { + s.mu.Unlock() + return + } + idle := time.Since(sess.lastActive) + if idle >= ttl { + delete(s.sessions, token) + s.mu.Unlock() + _ = sess.conn.Close() + return + } + next := ttl - idle + s.mu.Unlock() + + // Avoid a tight loop under high-frequency activity; we only need best-effort cleanup. + if next < 50*time.Millisecond { + next = 50 * time.Millisecond + } + timer.Reset(next) + } +} + +func (s *TunnelServer) sessionHas(token string) bool { + s.mu.Lock() + _, ok := s.sessions[token] + s.mu.Unlock() + return ok +} + +func (s *TunnelServer) sessionGet(token string) (*tunnelSession, bool) { + s.mu.Lock() + defer s.mu.Unlock() + sess, ok := s.sessions[token] + if !ok { + return nil, false + } + sess.lastActive = time.Now() + return sess, true +} + +func (s *TunnelServer) sessionClose(token string) { + s.mu.Lock() + sess, ok := s.sessions[token] + if ok { + delete(s.sessions, token) + } + s.mu.Unlock() + if ok { + _ = sess.conn.Close() + } +} + +func (s *TunnelServer) sessionCloseWrite(token string) { + sess, ok := s.sessionGet(token) + if !ok || sess == nil || sess.conn == nil { + return + } + if cw, ok := sess.conn.(interface{ CloseWrite() error }); ok { + _ = cw.CloseWrite() + return + } + _ = sess.conn.Close() +} + +func (s *TunnelServer) pollPush(rawConn net.Conn, token string, body io.Reader) (HandleResult, net.Conn, error) { + sess, ok := s.sessionGet(token) + if !ok { + _ = writeSimpleHTTPResponse(rawConn, http.StatusForbidden, "forbidden") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + payload, err := io.ReadAll(io.LimitReader(body, 1<<20)) // 1MiB per request cap + if err != nil { + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + lines := bytes.Split(payload, []byte{'\n'}) + for _, line := range lines { + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + decoded := make([]byte, base64.StdEncoding.DecodedLen(len(line))) + n, decErr := base64.StdEncoding.Decode(decoded, line) + if decErr != nil { + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } + if n == 0 { + continue + } + _ = sess.conn.SetWriteDeadline(time.Now().Add(30 * time.Second)) + _, werr := sess.conn.Write(decoded[:n]) + _ = sess.conn.SetWriteDeadline(time.Time{}) + if werr != nil { + s.sessionClose(token) + _ = writeSimpleHTTPResponse(rawConn, http.StatusGone, "gone") + _ = rawConn.Close() + return HandleDone, nil, nil + } + } + + _ = writeSimpleHTTPResponse(rawConn, http.StatusOK, "") + _ = rawConn.Close() + return HandleDone, nil, nil +} + +func (s *TunnelServer) streamPush(rawConn net.Conn, token string, body io.Reader) (HandleResult, net.Conn, error) { + sess, ok := s.sessionGet(token) + if !ok { + _ = writeSimpleHTTPResponse(rawConn, http.StatusForbidden, "forbidden") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + const maxUploadBytes = 1 << 20 + payload, err := io.ReadAll(io.LimitReader(body, maxUploadBytes+1)) + if err != nil { + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } + if len(payload) > maxUploadBytes { + _ = writeSimpleHTTPResponse(rawConn, http.StatusRequestEntityTooLarge, "too large") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + if len(payload) > 0 { + _ = sess.conn.SetWriteDeadline(time.Now().Add(30 * time.Second)) + _, werr := sess.conn.Write(payload) + _ = sess.conn.SetWriteDeadline(time.Time{}) + if werr != nil { + s.sessionClose(token) + _ = writeSimpleHTTPResponse(rawConn, http.StatusGone, "gone") + _ = rawConn.Close() + return HandleDone, nil, nil + } + } + + _ = writeSimpleHTTPResponse(rawConn, http.StatusOK, "") + _ = rawConn.Close() + return HandleDone, nil, nil +} + +func (s *TunnelServer) streamPull(rawConn net.Conn, token string) (HandleResult, net.Conn, error) { + sess, ok := s.sessionGet(token) + if !ok { + _ = writeSimpleHTTPResponse(rawConn, http.StatusForbidden, "forbidden") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + // Streaming response (chunked) with raw bytes (no base64 framing). + if err := writeTunnelResponseHeader(rawConn); err != nil { + _ = rawConn.Close() + return HandleDone, nil, err + } + + bw := bufio.NewWriterSize(rawConn, 32*1024) + cw := httputil.NewChunkedWriter(bw) + defer func() { + _ = cw.Close() + _, _ = bw.WriteString("\r\n") + _ = bw.Flush() + _ = rawConn.Close() + }() + + buf := make([]byte, 32*1024) + for { + _ = sess.conn.SetReadDeadline(time.Now().Add(s.pullReadTimeout)) + n, err := sess.conn.Read(buf) + if n > 0 { + _, _ = cw.Write(buf[:n]) + _ = bw.Flush() + } + if err != nil { + if errors.Is(err, os.ErrDeadlineExceeded) { + // End this long-poll response; client will re-issue. + return HandleDone, nil, nil + } + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) || errors.Is(err, net.ErrClosed) { + return HandleDone, nil, nil + } + s.sessionClose(token) + return HandleDone, nil, nil + } + } +} + +func (s *TunnelServer) pollPull(rawConn net.Conn, token string) (HandleResult, net.Conn, error) { + sess, ok := s.sessionGet(token) + if !ok { + _ = writeSimpleHTTPResponse(rawConn, http.StatusForbidden, "forbidden") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + // Streaming response (chunked) with base64 lines. + if err := writeTunnelResponseHeader(rawConn); err != nil { + _ = rawConn.Close() + return HandleDone, nil, err + } + + bw := bufio.NewWriterSize(rawConn, 32*1024) + cw := httputil.NewChunkedWriter(bw) + defer func() { + _ = cw.Close() + _, _ = bw.WriteString("\r\n") + _ = bw.Flush() + _ = rawConn.Close() + }() + + buf := make([]byte, 32*1024) + for { + _ = sess.conn.SetReadDeadline(time.Now().Add(s.pullReadTimeout)) + n, err := sess.conn.Read(buf) + if n > 0 { + line := make([]byte, base64.StdEncoding.EncodedLen(n)) + base64.StdEncoding.Encode(line, buf[:n]) + _, _ = cw.Write(append(line, '\n')) + _ = bw.Flush() + } + if err != nil { + if errors.Is(err, os.ErrDeadlineExceeded) { + // Keepalive: send an empty line then end this long-poll response. + _, _ = cw.Write([]byte("\n")) + _ = bw.Flush() + return HandleDone, nil, nil + } + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) || errors.Is(err, net.ErrClosed) { + return HandleDone, nil, nil + } + s.sessionClose(token) + return HandleDone, nil, nil + } + } +} diff --git a/transport/sudoku/obfs/httpmask/tunnel_ws.go b/transport/sudoku/obfs/httpmask/tunnel_ws.go new file mode 100644 index 00000000..0f7d18f8 --- /dev/null +++ b/transport/sudoku/obfs/httpmask/tunnel_ws.go @@ -0,0 +1,190 @@ +package httpmask + +import ( + "context" + "encoding/base64" + "fmt" + "io" + mrand "math/rand" + "net" + stdhttp "net/http" + "net/url" + "strings" + "time" + + "github.com/gobwas/ws" +) + +func normalizeWSSchemeFromAddress(serverAddress string, tlsEnabled bool) (string, string) { + addr := strings.TrimSpace(serverAddress) + if strings.Contains(addr, "://") { + if u, err := url.Parse(addr); err == nil && u != nil { + switch strings.ToLower(strings.TrimSpace(u.Scheme)) { + case "ws": + return "ws", u.Host + case "wss": + return "wss", u.Host + } + } + } + if tlsEnabled { + return "wss", addr + } + return "ws", addr +} + +func normalizeWSDialTarget(serverAddress string, tlsEnabled bool, hostOverride string) (scheme, urlHost, dialAddr, serverName string, err error) { + scheme, addr := normalizeWSSchemeFromAddress(serverAddress, tlsEnabled) + + host, port, err := net.SplitHostPort(addr) + if err != nil { + // Allow ws(s)://host without port. + if strings.Contains(addr, ":") { + return "", "", "", "", fmt.Errorf("invalid server address %q: %w", serverAddress, err) + } + switch scheme { + case "wss": + port = "443" + default: + port = "80" + } + host = addr + } + + if hostOverride != "" { + // Allow "example.com" or "example.com:443" + if h, p, splitErr := net.SplitHostPort(hostOverride); splitErr == nil { + if h != "" { + hostOverride = h + } + if p != "" { + port = p + } + } + serverName = hostOverride + urlHost = net.JoinHostPort(hostOverride, port) + } else { + serverName = host + urlHost = net.JoinHostPort(host, port) + } + + dialAddr = net.JoinHostPort(host, port) + return scheme, urlHost, dialAddr, trimPortForHost(serverName), nil +} + +func applyWSHeaders(h stdhttp.Header, host string) { + if h == nil { + return + } + r := rngPool.Get().(*mrand.Rand) + ua := userAgents[r.Intn(len(userAgents))] + accept := accepts[r.Intn(len(accepts))] + lang := acceptLanguages[r.Intn(len(acceptLanguages))] + enc := acceptEncodings[r.Intn(len(acceptEncodings))] + rngPool.Put(r) + + h.Set("User-Agent", ua) + h.Set("Accept", accept) + h.Set("Accept-Language", lang) + h.Set("Accept-Encoding", enc) + h.Set("Cache-Control", "no-cache") + h.Set("Pragma", "no-cache") + h.Set("X-Sudoku-Tunnel", string(TunnelModeWS)) + h.Set("X-Sudoku-Version", "1") +} + +func dialWS(ctx context.Context, serverAddress string, opts TunnelDialOptions) (net.Conn, error) { + if opts.DialContext == nil { + panic("httpmask: DialContext is nil") + } + + scheme, urlHost, dialAddr, _, err := normalizeWSDialTarget(serverAddress, opts.TLSConfig != nil, opts.HostOverride) + if err != nil { + return nil, err + } + + httpScheme := "http" + if scheme == "wss" { + httpScheme = "https" + } + headerHost := canonicalHeaderHost(urlHost, httpScheme) + auth := newTunnelAuth(opts.AuthKey, 0) + + u := &url.URL{ + Scheme: scheme, + Host: urlHost, + Path: joinPathRoot(opts.PathRoot, "/ws"), + } + if opts.EarlyHandshake != nil && len(opts.EarlyHandshake.RequestPayload) > 0 { + rawURL, err := setEarlyDataQuery(u.String(), opts.EarlyHandshake.RequestPayload) + if err != nil { + return nil, err + } + u, err = url.Parse(rawURL) + if err != nil { + return nil, err + } + } + + header := make(stdhttp.Header) + applyWSHeaders(header, headerHost) + + if auth != nil { + token := auth.token(TunnelModeWS, stdhttp.MethodGet, "/ws", time.Now()) + if token != "" { + header.Set("Authorization", "Bearer "+token) + q := u.Query() + q.Set(tunnelAuthQueryKey, token) + u.RawQuery = q.Encode() + } + } + + d := ws.Dialer{ + Host: headerHost, + Header: ws.HandshakeHeaderHTTP(header), + OnHeader: func(key, value []byte) error { + if !strings.EqualFold(string(key), tunnelEarlyDataHeader) || opts.EarlyHandshake == nil || opts.EarlyHandshake.HandleResponse == nil { + return nil + } + decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(string(value))) + if err != nil { + return err + } + return opts.EarlyHandshake.HandleResponse(decoded) + }, + NetDial: func(dialCtx context.Context, network, addr string) (net.Conn, error) { + if addr == urlHost { + addr = dialAddr + } + return opts.DialContext(dialCtx, network, addr) + }, + } + if scheme == "wss" { + if opts.TLSConfig == nil { + return nil, fmt.Errorf("httpmask: TLSConfig is required for wss") + } + d.TLSClient = func(conn net.Conn, hostname string) net.Conn { + tlsConn, _ := opts.TLSConfig.Client(conn) + return tlsConn + } + } + + conn, br, _, err := d.Dial(ctx, u.String()) + if err != nil { + return nil, err + } + + if br != nil && br.Buffered() > 0 { + pre := make([]byte, br.Buffered()) + _, _ = io.ReadFull(br, pre) + conn = newPreBufferedConn(conn, pre) + } + + wsConn := newWSStreamConn(conn, ws.StateClientSide) + upgraded, err := applyEarlyHandshakeOrUpgrade(wsConn, opts) + if err != nil { + _ = wsConn.Close() + return nil, err + } + return upgraded, nil +} diff --git a/transport/sudoku/obfs/httpmask/tunnel_ws_server.go b/transport/sudoku/obfs/httpmask/tunnel_ws_server.go new file mode 100644 index 00000000..b17b1ded --- /dev/null +++ b/transport/sudoku/obfs/httpmask/tunnel_ws_server.go @@ -0,0 +1,109 @@ +package httpmask + +import ( + "encoding/base64" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gobwas/ws" +) + +func looksLikeWebSocketUpgrade(headers map[string]string) bool { + if headers == nil { + return false + } + if !strings.EqualFold(strings.TrimSpace(headers["upgrade"]), "websocket") { + return false + } + conn := headers["connection"] + for _, part := range strings.Split(conn, ",") { + if strings.EqualFold(strings.TrimSpace(part), "upgrade") { + return true + } + } + return false +} + +func (s *TunnelServer) handleWS(rawConn net.Conn, req *httpRequestHeader, headerBytes []byte, buffered []byte) (HandleResult, net.Conn, error) { + rejectOrReply := func(code int, body string) (HandleResult, net.Conn, error) { + if s.passThroughOnReject { + prefix := make([]byte, 0, len(headerBytes)+len(buffered)) + prefix = append(prefix, headerBytes...) + prefix = append(prefix, buffered...) + return HandlePassThrough, newRejectedPreBufferedConn(rawConn, prefix), nil + } + _ = writeSimpleHTTPResponse(rawConn, code, body) + _ = rawConn.Close() + return HandleDone, nil, nil + } + + u, err := url.ParseRequestURI(req.target) + if err != nil { + return rejectOrReply(http.StatusBadRequest, "bad request") + } + + path, ok := stripPathRoot(s.pathRoot, u.Path) + if !ok || path != "/ws" { + return rejectOrReply(http.StatusNotFound, "not found") + } + if strings.ToUpper(strings.TrimSpace(req.method)) != http.MethodGet { + return rejectOrReply(http.StatusBadRequest, "bad request") + } + if !looksLikeWebSocketUpgrade(req.headers) { + return rejectOrReply(http.StatusBadRequest, "bad request") + } + + authVal := req.headers["authorization"] + if authVal == "" { + authVal = u.Query().Get(tunnelAuthQueryKey) + } + if !s.auth.verifyValue(authVal, TunnelModeWS, req.method, path, time.Now()) { + return rejectOrReply(http.StatusNotFound, "not found") + } + + earlyPayload, err := parseEarlyDataQuery(u) + if err != nil { + return rejectOrReply(http.StatusBadRequest, "bad request") + } + var prepared *PreparedServerEarlyHandshake + if len(earlyPayload) > 0 && s.earlyHandshake != nil && s.earlyHandshake.Prepare != nil { + prepared, err = s.earlyHandshake.Prepare(earlyPayload) + if err != nil { + return rejectOrReply(http.StatusNotFound, "not found") + } + } + + prefix := make([]byte, 0, len(headerBytes)+len(buffered)) + prefix = append(prefix, headerBytes...) + prefix = append(prefix, buffered...) + wsConnRaw := newPreBufferedConn(rawConn, prefix) + + upgrader := ws.Upgrader{} + if prepared != nil && len(prepared.ResponsePayload) > 0 { + upgrader.OnBeforeUpgrade = func() (ws.HandshakeHeader, error) { + h := http.Header{} + h.Set(tunnelEarlyDataHeader, base64.RawURLEncoding.EncodeToString(prepared.ResponsePayload)) + return ws.HandshakeHeaderHTTP(h), nil + } + } + if _, err := upgrader.Upgrade(wsConnRaw); err != nil { + _ = rawConn.Close() + return HandleDone, nil, nil + } + + outConn := net.Conn(newWSStreamConn(wsConnRaw, ws.StateServerSide)) + if prepared != nil && prepared.WrapConn != nil { + wrapped, err := prepared.WrapConn(outConn) + if err != nil { + _ = outConn.Close() + return HandleDone, nil, nil + } + if wrapped != nil { + outConn = wrapEarlyHandshakeConn(wrapped, prepared.UserHash) + } + } + return HandleStartTunnel, outConn, nil +} diff --git a/transport/sudoku/obfs/httpmask/ws_stream_conn.go b/transport/sudoku/obfs/httpmask/ws_stream_conn.go new file mode 100644 index 00000000..46fc3804 --- /dev/null +++ b/transport/sudoku/obfs/httpmask/ws_stream_conn.go @@ -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() +} diff --git a/transport/sudoku/obfs/sudoku/ascii_mode.go b/transport/sudoku/obfs/sudoku/ascii_mode.go new file mode 100644 index 00000000..8a05d471 --- /dev/null +++ b/transport/sudoku/obfs/sudoku/ascii_mode.go @@ -0,0 +1,93 @@ +package sudoku + +import ( + "fmt" + "strings" +) + +const ( + asciiModeTokenASCII = "ascii" + asciiModeTokenEntropy = "entropy" +) + +// ASCIIMode describes the preferred wire layout for each traffic direction. +// Uplink is client->server, Downlink is server->client. +type ASCIIMode struct { + Uplink string + Downlink string +} + +// ParseASCIIMode accepts legacy symmetric values ("ascii"/"entropy"/"prefer_*") +// and directional values like "up_ascii_down_entropy". +func ParseASCIIMode(mode string) (ASCIIMode, error) { + raw := strings.ToLower(strings.TrimSpace(mode)) + switch raw { + case "", "entropy", "prefer_entropy": + return ASCIIMode{Uplink: asciiModeTokenEntropy, Downlink: asciiModeTokenEntropy}, nil + case "ascii", "prefer_ascii": + return ASCIIMode{Uplink: asciiModeTokenASCII, Downlink: asciiModeTokenASCII}, nil + } + + if !strings.HasPrefix(raw, "up_") { + return ASCIIMode{}, fmt.Errorf("invalid ascii mode: %s", mode) + } + parts := strings.SplitN(strings.TrimPrefix(raw, "up_"), "_down_", 2) + if len(parts) != 2 { + return ASCIIMode{}, fmt.Errorf("invalid ascii mode: %s", mode) + } + + up, ok := normalizeASCIIModeToken(parts[0]) + if !ok { + return ASCIIMode{}, fmt.Errorf("invalid ascii mode: %s", mode) + } + down, ok := normalizeASCIIModeToken(parts[1]) + if !ok { + return ASCIIMode{}, fmt.Errorf("invalid ascii mode: %s", mode) + } + return ASCIIMode{Uplink: up, Downlink: down}, nil +} + +// NormalizeASCIIMode returns the canonical config string for a supported mode. +func NormalizeASCIIMode(mode string) (string, error) { + parsed, err := ParseASCIIMode(mode) + if err != nil { + return "", err + } + return parsed.Canonical(), nil +} + +func (m ASCIIMode) Canonical() string { + if m.Uplink == asciiModeTokenASCII && m.Downlink == asciiModeTokenASCII { + return "prefer_ascii" + } + if m.Uplink == asciiModeTokenEntropy && m.Downlink == asciiModeTokenEntropy { + return "prefer_entropy" + } + return "up_" + m.Uplink + "_down_" + m.Downlink +} + +func (m ASCIIMode) uplinkPreference() string { + return singleDirectionPreference(m.Uplink) +} + +func (m ASCIIMode) downlinkPreference() string { + return singleDirectionPreference(m.Downlink) +} + +func normalizeASCIIModeToken(token string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(token)) { + case "ascii", "prefer_ascii": + return asciiModeTokenASCII, true + case "entropy", "prefer_entropy", "": + return asciiModeTokenEntropy, true + default: + return "", false + } +} + +func singleDirectionPreference(token string) string { + if token == asciiModeTokenASCII { + return "prefer_ascii" + } + return "prefer_entropy" +} diff --git a/transport/sudoku/obfs/sudoku/conn.go b/transport/sudoku/obfs/sudoku/conn.go new file mode 100644 index 00000000..12998d5e --- /dev/null +++ b/transport/sudoku/obfs/sudoku/conn.go @@ -0,0 +1,193 @@ +package sudoku + +import ( + "bufio" + "bytes" + "net" + "sync" + "sync/atomic" +) + +const IOBufferSize = 32 * 1024 + +var perm4 = [24][4]byte{ + {0, 1, 2, 3}, + {0, 1, 3, 2}, + {0, 2, 1, 3}, + {0, 2, 3, 1}, + {0, 3, 1, 2}, + {0, 3, 2, 1}, + {1, 0, 2, 3}, + {1, 0, 3, 2}, + {1, 2, 0, 3}, + {1, 2, 3, 0}, + {1, 3, 0, 2}, + {1, 3, 2, 0}, + {2, 0, 1, 3}, + {2, 0, 3, 1}, + {2, 1, 0, 3}, + {2, 1, 3, 0}, + {2, 3, 0, 1}, + {2, 3, 1, 0}, + {3, 0, 1, 2}, + {3, 0, 2, 1}, + {3, 1, 0, 2}, + {3, 1, 2, 0}, + {3, 2, 0, 1}, + {3, 2, 1, 0}, +} + +type Conn struct { + net.Conn + table *Table + reader *bufio.Reader + recorder *bytes.Buffer + recording atomic.Bool + recordLock sync.Mutex + + rawBuf []byte + pendingData pendingBuffer + hintBuf [4]byte + hintCount int + writeMu sync.Mutex + writeBuf []byte + + rng randomSource + paddingThreshold uint64 +} + +func (sc *Conn) CloseWrite() error { + if sc == nil || sc.Conn == nil { + return nil + } + if cw, ok := sc.Conn.(interface{ CloseWrite() error }); ok { + return cw.CloseWrite() + } + return nil +} + +func (sc *Conn) CloseRead() error { + if sc == nil || sc.Conn == nil { + return nil + } + if cr, ok := sc.Conn.(interface{ CloseRead() error }); ok { + return cr.CloseRead() + } + return nil +} + +func NewConn(c net.Conn, table *Table, pMin, pMax int, record bool) *Conn { + localRng := newSeededRand() + + sc := &Conn{ + Conn: c, + table: table, + reader: bufio.NewReaderSize(c, IOBufferSize), + rawBuf: make([]byte, IOBufferSize), + pendingData: newPendingBuffer(4096), + writeBuf: make([]byte, 0, 4096), + rng: localRng, + paddingThreshold: pickPaddingThreshold(localRng, pMin, pMax), + } + if record { + sc.recorder = new(bytes.Buffer) + sc.recording.Store(true) + } + return sc +} + +func (sc *Conn) StopRecording() { + sc.recordLock.Lock() + sc.recording.Store(false) + sc.recorder = nil + sc.recordLock.Unlock() +} + +func (sc *Conn) GetBufferedAndRecorded() []byte { + if sc == nil { + return nil + } + + sc.recordLock.Lock() + defer sc.recordLock.Unlock() + + var recorded []byte + if sc.recorder != nil { + recorded = sc.recorder.Bytes() + } + + buffered := sc.reader.Buffered() + if buffered > 0 { + peeked, _ := sc.reader.Peek(buffered) + full := make([]byte, len(recorded)+len(peeked)) + copy(full, recorded) + copy(full[len(recorded):], peeked) + return full + } + return recorded +} + +func (sc *Conn) Write(p []byte) (n int, err error) { + if len(p) == 0 { + return 0, nil + } + + sc.writeMu.Lock() + defer sc.writeMu.Unlock() + + sc.writeBuf = encodeSudokuPayload(sc.writeBuf[:0], sc.table, sc.rng, sc.paddingThreshold, p) + return len(p), writeFull(sc.Conn, sc.writeBuf) +} + +func (sc *Conn) Read(p []byte) (n int, err error) { + if n, ok := drainPending(p, &sc.pendingData); ok { + return n, nil + } + + for { + if sc.pendingData.available() > 0 { + break + } + + nr, rErr := sc.reader.Read(sc.rawBuf) + if nr > 0 { + chunk := sc.rawBuf[:nr] + if sc.recording.Load() { + sc.recordLock.Lock() + if sc.recording.Load() && sc.recorder != nil { + sc.recorder.Write(chunk) + } + sc.recordLock.Unlock() + } + + layout := sc.table.layout + for _, b := range chunk { + if !layout.hintTable[b] { + continue + } + + sc.hintBuf[sc.hintCount] = b + sc.hintCount++ + if sc.hintCount == len(sc.hintBuf) { + key := packHintsToKey(sc.hintBuf) + val, ok := sc.table.DecodeMap[key] + if !ok { + return 0, ErrInvalidSudokuMapMiss + } + sc.pendingData.appendByte(val) + sc.hintCount = 0 + } + } + } + + if rErr != nil { + return 0, rErr + } + if sc.pendingData.available() > 0 { + break + } + } + + n, _ = drainPending(p, &sc.pendingData) + return n, nil +} diff --git a/transport/sudoku/obfs/sudoku/encode.go b/transport/sudoku/obfs/sudoku/encode.go new file mode 100644 index 00000000..cfcf571e --- /dev/null +++ b/transport/sudoku/obfs/sudoku/encode.go @@ -0,0 +1,36 @@ +package sudoku + +func encodeSudokuPayload(dst []byte, table *Table, rng randomSource, paddingThreshold uint64, p []byte) []byte { + if len(p) == 0 { + return dst[:0] + } + + outCapacity := len(p)*6 + 1 + if cap(dst) < outCapacity { + dst = make([]byte, 0, outCapacity) + } + out := dst[:0] + pads := table.PaddingPool + padLen := len(pads) + + for _, b := range p { + if shouldPad(rng, paddingThreshold) { + out = append(out, pads[rng.Intn(padLen)]) + } + + puzzles := table.EncodeTable[b] + puzzle := puzzles[rng.Intn(len(puzzles))] + perm := perm4[rng.Intn(len(perm4))] + for _, idx := range perm { + if shouldPad(rng, paddingThreshold) { + out = append(out, pads[rng.Intn(padLen)]) + } + out = append(out, puzzle[idx]) + } + } + + if shouldPad(rng, paddingThreshold) { + out = append(out, pads[rng.Intn(padLen)]) + } + return out +} diff --git a/transport/sudoku/obfs/sudoku/grid.go b/transport/sudoku/obfs/sudoku/grid.go new file mode 100644 index 00000000..3e802989 --- /dev/null +++ b/transport/sudoku/obfs/sudoku/grid.go @@ -0,0 +1,46 @@ +package sudoku + +// Grid represents a 4x4 sudoku grid +type Grid [16]uint8 + +// GenerateAllGrids generates all valid 4x4 Sudoku grids +func GenerateAllGrids() []Grid { + var grids []Grid + var g Grid + var backtrack func(int) + + backtrack = func(idx int) { + if idx == 16 { + grids = append(grids, g) + return + } + row, col := idx/4, idx%4 + br, bc := (row/2)*2, (col/2)*2 + for num := uint8(1); num <= 4; num++ { + valid := true + for i := 0; i < 4; i++ { + if g[row*4+i] == num || g[i*4+col] == num { + valid = false + break + } + } + if valid { + for r := 0; r < 2; r++ { + for c := 0; c < 2; c++ { + if g[(br+r)*4+(bc+c)] == num { + valid = false + break + } + } + } + } + if valid { + g[idx] = num + backtrack(idx + 1) + g[idx] = 0 + } + } + } + backtrack(0) + return grids +} diff --git a/transport/sudoku/obfs/sudoku/layout.go b/transport/sudoku/obfs/sudoku/layout.go new file mode 100644 index 00000000..2e1ec236 --- /dev/null +++ b/transport/sudoku/obfs/sudoku/layout.go @@ -0,0 +1,255 @@ +package sudoku + +import ( + "fmt" + "math/bits" + "sort" + "strings" +) + +type byteLayout struct { + name string + hintMask byte + hintValue byte + padMarker byte + paddingPool []byte + + hintTable [256]bool + encodeHint [4][16]byte + encodeGroup [64]byte + decodeGroup [256]byte + groupValid [256]bool +} + +func (l *byteLayout) isHint(b byte) bool { + return l != nil && l.hintTable[b] +} + +func (l *byteLayout) hintByte(val, pos byte) byte { + return l.encodeHint[val&0x03][pos&0x0F] +} + +func (l *byteLayout) groupByte(group byte) byte { + return l.encodeGroup[group&0x3F] +} + +func (l *byteLayout) decodePackedGroup(b byte) (byte, bool) { + if l == nil { + return 0, false + } + return l.decodeGroup[b], l.groupValid[b] +} + +// resolveLayout picks the byte layout for a single traffic direction. +// ASCII always wins if requested. Custom patterns are ignored when ASCII is preferred. +func resolveLayout(mode string, customPattern string) (*byteLayout, error) { + switch strings.ToLower(mode) { + case "ascii", "prefer_ascii": + return newASCIILayout(), nil + case "entropy", "prefer_entropy", "": + // fallback to entropy unless a custom pattern is provided + default: + return nil, fmt.Errorf("invalid ascii mode: %s", mode) + } + + if strings.TrimSpace(customPattern) != "" { + return newCustomLayout(customPattern) + } + return newEntropyLayout(), nil +} + +func newASCIILayout() *byteLayout { + padding := make([]byte, 0, 32) + for i := 0; i < 32; i++ { + padding = append(padding, byte(0x20+i)) + } + + layout := &byteLayout{ + name: "ascii", + hintMask: 0x40, + hintValue: 0x40, + padMarker: 0x3F, + paddingPool: padding, + } + + for val := 0; val < 4; val++ { + for pos := 0; pos < 16; pos++ { + b := byte(0x40 | (byte(val) << 4) | byte(pos)) + if b == 0x7F { + b = '\n' + } + layout.encodeHint[val][pos] = b + } + } + for group := 0; group < 64; group++ { + b := byte(0x40 | byte(group)) + if b == 0x7F { + b = '\n' + } + layout.encodeGroup[group] = b + } + for b := 0; b < 256; b++ { + wire := byte(b) + if (wire & 0x40) == 0x40 { + layout.hintTable[wire] = true + layout.decodeGroup[wire] = wire & 0x3F + layout.groupValid[wire] = true + } + } + layout.hintTable['\n'] = true + layout.decodeGroup['\n'] = 0x3F + layout.groupValid['\n'] = true + + return layout +} + +func newEntropyLayout() *byteLayout { + padding := make([]byte, 0, 16) + for i := 0; i < 8; i++ { + padding = append(padding, byte(0x80+i)) + padding = append(padding, byte(0x10+i)) + } + + layout := &byteLayout{ + name: "entropy", + hintMask: 0x90, + hintValue: 0x00, + padMarker: 0x80, + paddingPool: padding, + } + + for val := 0; val < 4; val++ { + for pos := 0; pos < 16; pos++ { + layout.encodeHint[val][pos] = (byte(val) << 5) | byte(pos) + } + } + for group := 0; group < 64; group++ { + v := byte(group) + layout.encodeGroup[group] = ((v & 0x30) << 1) | (v & 0x0F) + } + for b := 0; b < 256; b++ { + wire := byte(b) + if (wire & 0x90) != 0 { + continue + } + layout.hintTable[wire] = true + layout.decodeGroup[wire] = ((wire >> 1) & 0x30) | (wire & 0x0F) + layout.groupValid[wire] = true + } + + return layout +} + +func newCustomLayout(pattern string) (*byteLayout, error) { + cleaned := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(pattern), " ", "")) + if len(cleaned) != 8 { + return nil, fmt.Errorf("custom table must have 8 symbols, got %d", len(cleaned)) + } + + var xBits, pBits, vBits []uint8 + for i, c := range cleaned { + bit := uint8(7 - i) + switch c { + case 'x': + xBits = append(xBits, bit) + case 'p': + pBits = append(pBits, bit) + case 'v': + vBits = append(vBits, bit) + default: + return nil, fmt.Errorf("invalid char %q in custom table", c) + } + } + + if len(xBits) != 2 || len(pBits) != 2 || len(vBits) != 4 { + return nil, fmt.Errorf("custom table must contain exactly 2 x, 2 p, 4 v") + } + + xMask := byte(0) + for _, b := range xBits { + xMask |= 1 << b + } + + encodeBits := func(val, pos byte, dropX int) byte { + var out byte + out |= xMask + if dropX >= 0 { + out &^= 1 << xBits[dropX] + } + if (val & 0x02) != 0 { + out |= 1 << pBits[0] + } + if (val & 0x01) != 0 { + out |= 1 << pBits[1] + } + for i, bit := range vBits { + if (pos>>(3-uint8(i)))&0x01 == 1 { + out |= 1 << bit + } + } + return out + } + + paddingSet := make(map[byte]struct{}) + var padding []byte + for drop := range xBits { + for val := 0; val < 4; val++ { + for pos := 0; pos < 16; pos++ { + b := encodeBits(byte(val), byte(pos), drop) + if bits.OnesCount8(b) >= 5 { + if _, ok := paddingSet[b]; !ok { + paddingSet[b] = struct{}{} + padding = append(padding, b) + } + } + } + } + } + sort.Slice(padding, func(i, j int) bool { return padding[i] < padding[j] }) + if len(padding) == 0 { + return nil, fmt.Errorf("custom table produced empty padding pool") + } + + layout := &byteLayout{ + name: fmt.Sprintf("custom(%s)", cleaned), + hintMask: xMask, + hintValue: xMask, + padMarker: padding[0], + paddingPool: padding, + } + + for val := 0; val < 4; val++ { + for pos := 0; pos < 16; pos++ { + layout.encodeHint[val][pos] = encodeBits(byte(val), byte(pos), -1) + } + } + for group := 0; group < 64; group++ { + val := byte(group>>4) & 0x03 + pos := byte(group) & 0x0F + layout.encodeGroup[group] = encodeBits(val, pos, -1) + } + for b := 0; b < 256; b++ { + wire := byte(b) + if (wire & xMask) != xMask { + continue + } + layout.hintTable[wire] = true + + var val, pos byte + if wire&(1< packedProtectedPrefixBytes { + limit = packedProtectedPrefixBytes + } + + for padCount := 0; padCount < 1+pc.rng.Intn(2); padCount++ { + out = pc.appendForcedPadding(out) + } + + gap := pc.nextProtectedPrefixGap() + effective := 0 + for i := 0; i < limit; i++ { + pc.bitBuf = (pc.bitBuf << 8) | uint64(p[i]) + pc.bitCount += 8 + for pc.bitCount >= 6 { + pc.bitCount -= 6 + group := byte(pc.bitBuf >> pc.bitCount) + if pc.bitCount == 0 { + pc.bitBuf = 0 + } else { + pc.bitBuf &= (1 << pc.bitCount) - 1 + } + out = pc.appendGroup(out, group&0x3F) + } + + effective++ + if effective >= gap { + out = pc.appendForcedPadding(out) + effective = 0 + gap = pc.nextProtectedPrefixGap() + } + } + + return out, limit +} + +func (pc *PackedConn) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + + pc.writeMu.Lock() + defer pc.writeMu.Unlock() + + needed := len(p)*3/2 + 32 + if cap(pc.writeBuf) < needed { + pc.writeBuf = make([]byte, 0, needed) + } + out := pc.writeBuf[:0] + + var prefixN int + out, prefixN = pc.writeProtectedPrefix(out, p) + + i := prefixN + n := len(p) + + for pc.bitCount > 0 && i < n { + b := p[i] + i++ + pc.bitBuf = (pc.bitBuf << 8) | uint64(b) + pc.bitCount += 8 + for pc.bitCount >= 6 { + pc.bitCount -= 6 + group := byte(pc.bitBuf >> pc.bitCount) + if pc.bitCount == 0 { + pc.bitBuf = 0 + } else { + pc.bitBuf &= (1 << pc.bitCount) - 1 + } + out = pc.appendGroup(out, group&0x3F) + } + } + + for i+11 < n { + for batch := 0; batch < 4; batch++ { + b1, b2, b3 := p[i], p[i+1], p[i+2] + i += 3 + + g1 := (b1 >> 2) & 0x3F + g2 := ((b1 & 0x03) << 4) | ((b2 >> 4) & 0x0F) + g3 := ((b2 & 0x0F) << 2) | ((b3 >> 6) & 0x03) + g4 := b3 & 0x3F + + out = pc.appendGroup(out, g1) + out = pc.appendGroup(out, g2) + out = pc.appendGroup(out, g3) + out = pc.appendGroup(out, g4) + } + } + + for i+2 < n { + b1, b2, b3 := p[i], p[i+1], p[i+2] + i += 3 + + g1 := (b1 >> 2) & 0x3F + g2 := ((b1 & 0x03) << 4) | ((b2 >> 4) & 0x0F) + g3 := ((b2 & 0x0F) << 2) | ((b3 >> 6) & 0x03) + g4 := b3 & 0x3F + + out = pc.appendGroup(out, g1) + out = pc.appendGroup(out, g2) + out = pc.appendGroup(out, g3) + out = pc.appendGroup(out, g4) + } + + for ; i < n; i++ { + b := p[i] + pc.bitBuf = (pc.bitBuf << 8) | uint64(b) + pc.bitCount += 8 + for pc.bitCount >= 6 { + pc.bitCount -= 6 + group := byte(pc.bitBuf >> pc.bitCount) + if pc.bitCount == 0 { + pc.bitBuf = 0 + } else { + pc.bitBuf &= (1 << pc.bitCount) - 1 + } + out = pc.appendGroup(out, group&0x3F) + } + } + + if pc.bitCount > 0 { + group := byte(pc.bitBuf << (6 - pc.bitCount)) + pc.bitBuf = 0 + pc.bitCount = 0 + out = pc.appendGroup(out, group&0x3F) + out = append(out, pc.padMarker) + } + + out = pc.maybeAddPadding(out) + + if len(out) > 0 { + pc.writeBuf = out[:0] + return len(p), writeFull(pc.Conn, out) + } + pc.writeBuf = out[:0] + return len(p), nil +} + +func (pc *PackedConn) Flush() error { + pc.writeMu.Lock() + defer pc.writeMu.Unlock() + + out := pc.writeBuf[:0] + if pc.bitCount > 0 { + group := byte(pc.bitBuf << (6 - pc.bitCount)) + pc.bitBuf = 0 + pc.bitCount = 0 + + out = append(out, pc.table.layout.groupByte(group&0x3F)) + out = append(out, pc.padMarker) + } + + out = pc.maybeAddPadding(out) + + if len(out) > 0 { + pc.writeBuf = out[:0] + return writeFull(pc.Conn, out) + } + return nil +} + +func writeFull(w io.Writer, b []byte) error { + for len(b) > 0 { + n, err := w.Write(b) + if err != nil { + return err + } + if n == 0 { + return io.ErrShortWrite + } + b = b[n:] + } + return nil +} + +func (pc *PackedConn) Read(p []byte) (int, error) { + if n, ok := drainPending(p, &pc.pendingData); ok { + return n, nil + } + + for { + nr, rErr := pc.reader.Read(pc.rawBuf) + if nr > 0 { + rBuf := pc.readBitBuf + rBits := pc.readBits + padMarker := pc.padMarker + layout := pc.table.layout + + for _, b := range pc.rawBuf[:nr] { + if !layout.hintTable[b] { + if b == padMarker { + rBuf = 0 + rBits = 0 + } + continue + } + + group, ok := layout.decodePackedGroup(b) + if !ok { + return 0, ErrInvalidSudokuMapMiss + } + + rBuf = (rBuf << 6) | uint64(group) + rBits += 6 + + if rBits >= 8 { + rBits -= 8 + val := byte(rBuf >> rBits) + pc.pendingData.appendByte(val) + if rBits == 0 { + rBuf = 0 + } else { + rBuf &= (uint64(1) << rBits) - 1 + } + } + } + + pc.readBitBuf = rBuf + pc.readBits = rBits + } + + if rErr != nil { + if rErr == io.EOF { + pc.readBitBuf = 0 + pc.readBits = 0 + } + if pc.pendingData.available() > 0 { + break + } + return 0, rErr + } + + if pc.pendingData.available() > 0 { + break + } + } + + n, _ := drainPending(p, &pc.pendingData) + return n, nil +} + +func (pc *PackedConn) getPaddingByte() byte { + return pc.padPool[pc.rng.Intn(len(pc.padPool))] +} diff --git a/transport/sudoku/obfs/sudoku/padding_prob.go b/transport/sudoku/obfs/sudoku/padding_prob.go new file mode 100644 index 00000000..00ff68ff --- /dev/null +++ b/transport/sudoku/obfs/sudoku/padding_prob.go @@ -0,0 +1,42 @@ +package sudoku + +const probOne = uint64(1) << 32 + +func pickPaddingThreshold(r randomSource, pMin, pMax int) uint64 { + if r == nil { + return 0 + } + if pMin < 0 { + pMin = 0 + } + if pMax < pMin { + pMax = pMin + } + if pMax > 100 { + pMax = 100 + } + if pMin > 100 { + pMin = 100 + } + + min := uint64(pMin) * probOne / 100 + max := uint64(pMax) * probOne / 100 + if max <= min { + return min + } + u := uint64(r.Uint32()) + return min + (u * (max - min) >> 32) +} + +func shouldPad(r randomSource, threshold uint64) bool { + if threshold == 0 { + return false + } + if threshold >= probOne { + return true + } + if r == nil { + return false + } + return uint64(r.Uint32()) < threshold +} diff --git a/transport/sudoku/obfs/sudoku/pending.go b/transport/sudoku/obfs/sudoku/pending.go new file mode 100644 index 00000000..60f09a12 --- /dev/null +++ b/transport/sudoku/obfs/sudoku/pending.go @@ -0,0 +1,57 @@ +package sudoku + +type pendingBuffer struct { + data []byte + off int +} + +func newPendingBuffer(capacity int) pendingBuffer { + return pendingBuffer{data: make([]byte, 0, capacity)} +} + +func (p *pendingBuffer) available() int { + if p == nil { + return 0 + } + return len(p.data) - p.off +} + +func (p *pendingBuffer) reset() { + if p == nil { + return + } + p.data = p.data[:0] + p.off = 0 +} + +func (p *pendingBuffer) ensureAppendCapacity(extra int) { + if p == nil || extra <= 0 || p.off == 0 { + return + } + if cap(p.data)-len(p.data) >= extra { + return + } + + unread := len(p.data) - p.off + copy(p.data[:unread], p.data[p.off:]) + p.data = p.data[:unread] + p.off = 0 +} + +func (p *pendingBuffer) appendByte(b byte) { + p.ensureAppendCapacity(1) + p.data = append(p.data, b) +} + +func drainPending(dst []byte, pending *pendingBuffer) (int, bool) { + if pending == nil || pending.available() == 0 { + return 0, false + } + + n := copy(dst, pending.data[pending.off:]) + pending.off += n + if pending.off == len(pending.data) { + pending.reset() + } + return n, true +} diff --git a/transport/sudoku/obfs/sudoku/rand.go b/transport/sudoku/obfs/sudoku/rand.go new file mode 100644 index 00000000..abe72d80 --- /dev/null +++ b/transport/sudoku/obfs/sudoku/rand.go @@ -0,0 +1,56 @@ +package sudoku + +import ( + crypto_rand "crypto/rand" + "encoding/binary" + "time" +) + +type randomSource interface { + Uint32() uint32 + Uint64() uint64 + Intn(n int) int +} + +type sudokuRand struct { + state uint64 +} + +func newSeededRand() *sudokuRand { + seed := time.Now().UnixNano() + var seedBytes [8]byte + if _, err := crypto_rand.Read(seedBytes[:]); err == nil { + seed = int64(binary.BigEndian.Uint64(seedBytes[:])) + } + return newSudokuRand(seed) +} + +func newSudokuRand(seed int64) *sudokuRand { + state := uint64(seed) + if state == 0 { + state = 0x9e3779b97f4a7c15 + } + return &sudokuRand{state: state} +} + +func (r *sudokuRand) Uint64() uint64 { + if r == nil { + return 0 + } + r.state += 0x9e3779b97f4a7c15 + z := r.state + z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9 + z = (z ^ (z >> 27)) * 0x94d049bb133111eb + return z ^ (z >> 31) +} + +func (r *sudokuRand) Uint32() uint32 { + return uint32(r.Uint64() >> 32) +} + +func (r *sudokuRand) Intn(n int) int { + if n <= 1 { + return 0 + } + return int((uint64(r.Uint32()) * uint64(n)) >> 32) +} diff --git a/transport/sudoku/obfs/sudoku/table.go b/transport/sudoku/obfs/sudoku/table.go new file mode 100644 index 00000000..b32506ae --- /dev/null +++ b/transport/sudoku/obfs/sudoku/table.go @@ -0,0 +1,214 @@ +package sudoku + +import ( + "crypto/sha256" + "encoding/binary" + "errors" + "math/rand" + "strings" +) + +var ( + ErrInvalidSudokuMapMiss = errors.New("INVALID_SUDOKU_MAP_MISS") +) + +type Table struct { + EncodeTable [256][][4]byte + DecodeMap map[uint32]byte + PaddingPool []byte + IsASCII bool // 标记当前模式 + layout *byteLayout + opposite *Table + hint uint32 +} + +// NewTable initializes the obfuscation tables with built-in layouts. +// Equivalent to calling NewTableWithCustom(key, mode, ""). +func NewTable(key string, mode string) *Table { + t, err := NewTableWithCustom(key, mode, "") + if err != nil { + panic(err) + } + return t +} + +// NewTableWithCustom initializes the uplink/probe Sudoku table using either predefined +// or directional layouts. Directional modes such as "up_ascii_down_entropy" return the +// client->server table and internally attach the opposite direction table for runtime use. +// The customPattern must contain 8 characters with exactly 2 x, 2 p, and 4 v (case-insensitive). +func NewTableWithCustom(key string, mode string, customPattern string) (*Table, error) { + asciiMode, err := ParseASCIIMode(mode) + if err != nil { + return nil, err + } + + uplinkPattern := customPatternForToken(asciiMode.Uplink, customPattern) + downlinkPattern := customPatternForToken(asciiMode.Downlink, customPattern) + hint := tableHintFingerprint(key, asciiMode.Canonical(), uplinkPattern, downlinkPattern) + + uplink, err := newSingleDirectionTable(key, asciiMode.uplinkPreference(), uplinkPattern) + if err != nil { + return nil, err + } + uplink.hint = hint + if asciiMode.Uplink == asciiMode.Downlink { + uplink.opposite = uplink + return uplink, nil + } + + downlink, err := newSingleDirectionTable(key, asciiMode.downlinkPreference(), downlinkPattern) + if err != nil { + return nil, err + } + downlink.hint = hint + uplink.opposite = downlink + downlink.opposite = uplink + return uplink, nil +} + +func newSingleDirectionTable(key string, mode string, customPattern string) (*Table, error) { + layout, err := resolveLayout(mode, customPattern) + if err != nil { + return nil, err + } + + t := &Table{ + DecodeMap: make(map[uint32]byte), + IsASCII: layout.name == "ascii", + layout: layout, + } + t.PaddingPool = append(t.PaddingPool, layout.paddingPool...) + + // 生成数独网格 (逻辑不变) + allGrids := GenerateAllGrids() + h := sha256.New() + h.Write([]byte(key)) + seed := int64(binary.BigEndian.Uint64(h.Sum(nil)[:8])) + rng := rand.New(rand.NewSource(seed)) + + shuffledGrids := make([]Grid, 288) + copy(shuffledGrids, allGrids) + rng.Shuffle(len(shuffledGrids), func(i, j int) { + shuffledGrids[i], shuffledGrids[j] = shuffledGrids[j], shuffledGrids[i] + }) + + // 预计算组合 + var combinations [][]int + var combine func(int, int, []int) + combine = func(s, k int, c []int) { + if k == 0 { + tmp := make([]int, len(c)) + copy(tmp, c) + combinations = append(combinations, tmp) + return + } + for i := s; i <= 16-k; i++ { + c = append(c, i) + combine(i+1, k-1, c) + c = c[:len(c)-1] + } + } + combine(0, 4, []int{}) + + // 构建映射表 + for byteVal := 0; byteVal < 256; byteVal++ { + targetGrid := shuffledGrids[byteVal] + for _, positions := range combinations { + var currentHints [4]byte + + // 1. 计算抽象提示 (Abstract Hints) + // 我们先计算出 val 和 pos,后面再根据模式编码成 byte + var rawParts [4]struct{ val, pos byte } + + for i, pos := range positions { + val := targetGrid[pos] // 1..4 + rawParts[i] = struct{ val, pos byte }{val, uint8(pos)} + } + + // 检查唯一性 (数独逻辑) + matchCount := 0 + for _, g := range allGrids { + match := true + for _, p := range rawParts { + if g[p.pos] != p.val { + match = false + break + } + } + if match { + matchCount++ + if matchCount > 1 { + break + } + } + } + + if matchCount == 1 { + // 唯一确定,生成最终编码字节 + for i, p := range rawParts { + currentHints[i] = t.layout.hintByte(p.val-1, p.pos) + } + + t.EncodeTable[byteVal] = append(t.EncodeTable[byteVal], currentHints) + // 生成解码键 (需要对 Hints 进行排序以忽略传输顺序) + key := packHintsToKey(currentHints) + t.DecodeMap[key] = byte(byteVal) + } + } + } + return t, nil +} + +func customPatternForToken(token string, customPattern string) string { + if token == asciiModeTokenEntropy { + return customPattern + } + return "" +} + +func (t *Table) OppositeDirection() *Table { + if t == nil || t.opposite == nil { + return t + } + return t.opposite +} + +func (t *Table) Hint() uint32 { + if t == nil { + return 0 + } + return t.hint +} + +func tableHintFingerprint(key string, mode string, uplinkPattern string, downlinkPattern string) uint32 { + sum := sha256.Sum256([]byte(strings.Join([]string{ + "sudoku-table-hint", + key, + mode, + strings.ToLower(strings.TrimSpace(uplinkPattern)), + strings.ToLower(strings.TrimSpace(downlinkPattern)), + }, "\x00"))) + return binary.BigEndian.Uint32(sum[:4]) +} + +func packHintsToKey(hints [4]byte) uint32 { + // Sorting network for 4 elements (Bubble sort unrolled) + // Swap if a > b + if hints[0] > hints[1] { + hints[0], hints[1] = hints[1], hints[0] + } + if hints[2] > hints[3] { + hints[2], hints[3] = hints[3], hints[2] + } + if hints[0] > hints[2] { + hints[0], hints[2] = hints[2], hints[0] + } + if hints[1] > hints[3] { + hints[1], hints[3] = hints[3], hints[1] + } + if hints[1] > hints[2] { + hints[1], hints[2] = hints[2], hints[1] + } + + return uint32(hints[0])<<24 | uint32(hints[1])<<16 | uint32(hints[2])<<8 | uint32(hints[3]) +} diff --git a/transport/sudoku/obfs/sudoku/table_set.go b/transport/sudoku/obfs/sudoku/table_set.go new file mode 100644 index 00000000..59d3c98f --- /dev/null +++ b/transport/sudoku/obfs/sudoku/table_set.go @@ -0,0 +1,38 @@ +package sudoku + +import "fmt" + +// TableSet is a small helper for managing multiple Sudoku tables (e.g. for per-connection rotation). +// It is intentionally decoupled from the tunnel/app layers. +type TableSet struct { + Tables []*Table +} + +// NewTableSet builds one or more tables from key/mode and a list of custom X/P/V patterns. +// If patterns is empty, it builds a single default table (customPattern=""). +func NewTableSet(key string, mode string, patterns []string) (*TableSet, error) { + if len(patterns) == 0 { + t, err := NewTableWithCustom(key, mode, "") + if err != nil { + return nil, err + } + return &TableSet{Tables: []*Table{t}}, nil + } + + tables := make([]*Table, 0, len(patterns)) + for i, pattern := range patterns { + t, err := NewTableWithCustom(key, mode, pattern) + if err != nil { + return nil, fmt.Errorf("build table[%d] (%q): %w", i, pattern, err) + } + tables = append(tables, t) + } + return &TableSet{Tables: tables}, nil +} + +func (ts *TableSet) Candidates() []*Table { + if ts == nil { + return nil + } + return ts.Tables +} diff --git a/transport/sudoku/replay.go b/transport/sudoku/replay.go new file mode 100644 index 00000000..b2695082 --- /dev/null +++ b/transport/sudoku/replay.go @@ -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{} diff --git a/transport/sudoku/session_keys.go b/transport/sudoku/session_keys.go new file mode 100644 index 00000000..48971f97 --- /dev/null +++ b/transport/sudoku/session_keys.go @@ -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 +} diff --git a/transport/sudoku/table_probe.go b/transport/sudoku/table_probe.go new file mode 100644 index 00000000..4cb5b040 --- /dev/null +++ b/transport/sudoku/table_probe.go @@ -0,0 +1,164 @@ +package sudoku + +import ( + "bufio" + "bytes" + crand "crypto/rand" + "errors" + "fmt" + "io" + "net" + "time" + + "github.com/sagernet/sing-box/transport/sudoku/crypto" + "github.com/sagernet/sing-box/transport/sudoku/obfs/sudoku" +) + +type clientTableChoice struct { + Table *sudoku.Table + Hint uint32 + HasHint bool +} + +func pickClientTable(cfg *ProtocolConfig) (clientTableChoice, error) { + candidates := cfg.tableCandidates() + if len(candidates) == 0 { + return clientTableChoice{}, fmt.Errorf("no table configured") + } + if len(candidates) == 1 { + return clientTableChoice{Table: candidates[0], Hint: candidates[0].Hint()}, nil + } + var b [1]byte + if _, err := crand.Read(b[:]); err != nil { + return clientTableChoice{}, fmt.Errorf("random table pick failed: %w", err) + } + idx := int(b[0]) % len(candidates) + return clientTableChoice{Table: candidates[idx], Hint: candidates[idx].Hint(), HasHint: true}, nil +} + +type readOnlyConn struct { + *bytes.Reader +} + +func (c *readOnlyConn) Write([]byte) (int, error) { return 0, io.ErrClosedPipe } +func (c *readOnlyConn) Close() error { return nil } +func (c *readOnlyConn) LocalAddr() net.Addr { return nil } +func (c *readOnlyConn) RemoteAddr() net.Addr { return nil } +func (c *readOnlyConn) SetDeadline(time.Time) error { return nil } +func (c *readOnlyConn) SetReadDeadline(time.Time) error { return nil } +func (c *readOnlyConn) SetWriteDeadline(time.Time) error { return nil } + +func drainBuffered(r *bufio.Reader) ([]byte, error) { + n := r.Buffered() + if n <= 0 { + return nil, nil + } + out := make([]byte, n) + _, err := io.ReadFull(r, out) + return out, err +} + +func probeHandshakeBytes(probe []byte, cfg *ProtocolConfig, table *sudoku.Table) error { + rc := &readOnlyConn{Reader: bytes.NewReader(probe)} + _, obfsConn := buildServerObfsConn(rc, cfg, table, false) + seed := ServerAEADSeed(cfg.Key) + pskC2S, pskS2C := derivePSKDirectionalBases(seed) + // Server side: recv is client->server, send is server->client. + cConn, err := crypto.NewRecordConn(obfsConn, cfg.AEADMethod, pskS2C, pskC2S) + if err != nil { + return err + } + + msg, err := ReadKIPMessage(cConn) + if err != nil { + return err + } + if msg.Type != KIPTypeClientHello { + return fmt.Errorf("unexpected handshake message: %d", msg.Type) + } + ch, err := DecodeKIPClientHelloPayload(msg.Payload) + if err != nil { + return err + } + if absInt64(time.Now().Unix()-ch.Timestamp.Unix()) > int64(kipHandshakeSkew.Seconds()) { + return fmt.Errorf("time skew/replay") + } + + return nil +} + +func selectTableByProbe(r *bufio.Reader, cfg *ProtocolConfig, tables []*sudoku.Table) (*sudoku.Table, []byte, error) { + const ( + maxProbeBytes = 64 * 1024 + readChunk = 4 * 1024 + ) + if len(tables) == 0 { + return nil, nil, fmt.Errorf("no table candidates") + } + if len(tables) > 255 { + return nil, nil, fmt.Errorf("too many table candidates: %d", len(tables)) + } + + // Copy so we can prune candidates without mutating the caller slice. + candidates := make([]*sudoku.Table, 0, len(tables)) + for _, t := range tables { + if t != nil { + candidates = append(candidates, t) + } + } + if len(candidates) == 0 { + return nil, nil, fmt.Errorf("no table candidates") + } + + probe, err := drainBuffered(r) + if err != nil { + return nil, nil, fmt.Errorf("drain buffered bytes failed: %w", err) + } + + tmp := make([]byte, readChunk) + for { + if len(candidates) == 1 { + tail, err := drainBuffered(r) + if err != nil { + return nil, nil, fmt.Errorf("drain buffered bytes failed: %w", err) + } + probe = append(probe, tail...) + return candidates[0], probe, nil + } + + needMore := false + next := candidates[:0] + for _, table := range candidates { + err := probeHandshakeBytes(probe, cfg, table) + if err == nil { + tail, err := drainBuffered(r) + if err != nil { + return nil, nil, fmt.Errorf("drain buffered bytes failed: %w", err) + } + probe = append(probe, tail...) + return table, probe, nil + } + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + needMore = true + next = append(next, table) + } + // Definitive mismatch: drop table. + } + candidates = next + + if len(candidates) == 0 || !needMore { + return nil, probe, fmt.Errorf("handshake table selection failed") + } + if len(probe) >= maxProbeBytes { + return nil, probe, fmt.Errorf("handshake probe exceeded %d bytes", maxProbeBytes) + } + + n, err := r.Read(tmp) + if n > 0 { + probe = append(probe, tmp[:n]...) + } + if err != nil { + return nil, probe, fmt.Errorf("handshake probe read failed: %w", err) + } + } +} diff --git a/transport/sudoku/tables.go b/transport/sudoku/tables.go new file mode 100644 index 00000000..90eb5582 --- /dev/null +++ b/transport/sudoku/tables.go @@ -0,0 +1,68 @@ +package sudoku + +import ( + "strings" + + "github.com/sagernet/sing-box/transport/sudoku/obfs/sudoku" +) + +func normalizeCustomPatterns(customTable string, customTables []string) []string { + patterns := customTables + if len(patterns) == 0 && strings.TrimSpace(customTable) != "" { + patterns = []string{customTable} + } + if len(patterns) == 0 { + patterns = []string{""} + } + return patterns +} + +func normalizeTablePatterns(tableType string, customTable string, customTables []string) ([]string, error) { + patterns := normalizeCustomPatterns(customTable, customTables) + if _, err := sudoku.ParseASCIIMode(tableType); err != nil { + return nil, err + } + return patterns, nil +} + +// NewTablesWithCustomPatterns builds one or more obfuscation tables from x/v/p custom patterns. +// When customTables is non-empty it overrides customTable (matching upstream Sudoku behavior). +// +// Deprecated-ish: prefer NewClientTablesWithCustomPatterns / NewServerTablesWithCustomPatterns. +func NewTablesWithCustomPatterns(key string, tableType string, customTable string, customTables []string) ([]*sudoku.Table, error) { + patterns, err := normalizeTablePatterns(tableType, customTable, customTables) + if err != nil { + return nil, err + } + tables := make([]*sudoku.Table, 0, len(patterns)) + for _, pattern := range patterns { + pattern = strings.TrimSpace(pattern) + t, err := NewTableWithCustom(key, tableType, pattern) + if err != nil { + return nil, err + } + tables = append(tables, t) + } + return tables, nil +} + +func NewClientTablesWithCustomPatterns(key string, tableType string, customTable string, customTables []string) ([]*sudoku.Table, error) { + return NewTablesWithCustomPatterns(key, tableType, customTable, customTables) +} + +// NewServerTablesWithCustomPatterns matches upstream server behavior: when probeable custom table +// rotation is enabled, also accept the default table to avoid forcing clients to update in lockstep. +func NewServerTablesWithCustomPatterns(key string, tableType string, customTable string, customTables []string) ([]*sudoku.Table, error) { + patterns, err := normalizeTablePatterns(tableType, customTable, customTables) + if err != nil { + return nil, err + } + asciiMode, err := sudoku.ParseASCIIMode(tableType) + if err != nil { + return nil, err + } + if asciiMode.Uplink == "entropy" && len(patterns) > 0 && strings.TrimSpace(patterns[0]) != "" { + patterns = append([]string{""}, patterns...) + } + return NewTablesWithCustomPatterns(key, tableType, "", patterns) +} diff --git a/transport/sudoku/uot.go b/transport/sudoku/uot.go new file mode 100644 index 00000000..ed8b5cb5 --- /dev/null +++ b/transport/sudoku/uot.go @@ -0,0 +1,165 @@ +package sudoku + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "net/netip" + "sync" + "time" + +) + +const ( + maxUoTPayload = 64 * 1024 +) + +// WriteDatagram sends a single UDP datagram frame over a reliable stream. +func WriteDatagram(w io.Writer, addr string, payload []byte) error { + addrBuf, err := EncodeAddress(addr) + if err != nil { + return fmt.Errorf("encode address: %w", err) + } + + if addrLen := len(addrBuf); addrLen == 0 || addrLen > maxUoTPayload { + return fmt.Errorf("address too long: %d", len(addrBuf)) + } + if payloadLen := len(payload); payloadLen > maxUoTPayload { + return fmt.Errorf("payload too large: %d", payloadLen) + } + + var header [4]byte + binary.BigEndian.PutUint16(header[:2], uint16(len(addrBuf))) + binary.BigEndian.PutUint16(header[2:], uint16(len(payload))) + + return writeAllChunks(w, header[:], addrBuf, payload) +} + +// ReadDatagram parses a single UDP datagram frame from the reliable stream. +func ReadDatagram(r io.Reader) (string, []byte, error) { + addr, payloadLen, err := readDatagramHeaderAndAddress(r) + if err != nil { + return "", nil, err + } + payload := make([]byte, payloadLen) + if _, err := io.ReadFull(r, payload); err != nil { + return "", nil, err + } + + return addr, payload, nil +} + +// UoTPacketConn adapts a net.Conn with the Sudoku UoT framing to net.PacketConn. +type UoTPacketConn struct { + conn net.Conn + writeMu sync.Mutex +} + +func NewUoTPacketConn(conn net.Conn) *UoTPacketConn { + return &UoTPacketConn{conn: conn} +} + +func (c *UoTPacketConn) ReadFrom(p []byte) (int, net.Addr, error) { + for { + addrStr, payloadLen, err := readDatagramHeaderAndAddress(c.conn) + if err != nil { + return 0, nil, err + } + + udpAddr, err := parseDatagramUDPAddr(addrStr) + if payloadLen > len(p) { + if discardErr := discardBytes(c.conn, payloadLen); discardErr != nil { + return 0, nil, discardErr + } + return 0, nil, io.ErrShortBuffer + } + if err != nil { + if discardErr := discardBytes(c.conn, payloadLen); discardErr != nil { + return 0, nil, discardErr + } + continue + } + if _, err := io.ReadFull(c.conn, p[:payloadLen]); err != nil { + return 0, nil, err + } + return payloadLen, udpAddr, nil + } +} + +func (c *UoTPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) { + if addr == nil { + return 0, errors.New("address is nil") + } + c.writeMu.Lock() + defer c.writeMu.Unlock() + if err := WriteDatagram(c.conn, addr.String(), p); err != nil { + return 0, err + } + return len(p), nil +} + +func (c *UoTPacketConn) Close() error { + return c.conn.Close() +} + +func (c *UoTPacketConn) LocalAddr() net.Addr { + return c.conn.LocalAddr() +} + +func (c *UoTPacketConn) SetDeadline(t time.Time) error { + return c.conn.SetDeadline(t) +} + +func (c *UoTPacketConn) SetReadDeadline(t time.Time) error { + return c.conn.SetReadDeadline(t) +} + +func (c *UoTPacketConn) SetWriteDeadline(t time.Time) error { + return c.conn.SetWriteDeadline(t) +} + +func readDatagramHeaderAndAddress(r io.Reader) (string, int, error) { + var header [4]byte + if _, err := io.ReadFull(r, header[:]); err != nil { + return "", 0, err + } + + addrLen := int(binary.BigEndian.Uint16(header[:2])) + payloadLen := int(binary.BigEndian.Uint16(header[2:])) + if addrLen <= 0 || addrLen > maxUoTPayload { + return "", 0, fmt.Errorf("invalid address length: %d", addrLen) + } + if payloadLen < 0 || payloadLen > maxUoTPayload { + return "", 0, fmt.Errorf("invalid payload length: %d", payloadLen) + } + + addrBuf := make([]byte, addrLen) + if _, err := io.ReadFull(r, addrBuf); err != nil { + return "", 0, err + } + + addr, err := DecodeAddress(bytes.NewReader(addrBuf)) + if err != nil { + return "", 0, fmt.Errorf("decode address: %w", err) + } + return addr, payloadLen, nil +} + +func parseDatagramUDPAddr(addr string) (*net.UDPAddr, error) { + addrPort, err := netip.ParseAddrPort(addr) + if err != nil { + return nil, err + } + return net.UDPAddrFromAddrPort(netip.AddrPortFrom(addrPort.Addr().Unmap(), addrPort.Port())), nil +} + +func discardBytes(r io.Reader, n int) error { + if n <= 0 { + return nil + } + _, err := io.CopyN(io.Discard, r, int64(n)) + return err +} diff --git a/transport/sudoku/write_chunks.go b/transport/sudoku/write_chunks.go new file mode 100644 index 00000000..9c4ee8c7 --- /dev/null +++ b/transport/sudoku/write_chunks.go @@ -0,0 +1,19 @@ +package sudoku + +import "io" + +func writeAllChunks(w io.Writer, chunks ...[]byte) error { + for _, chunk := range chunks { + for len(chunk) > 0 { + n, err := w.Write(chunk) + if err != nil { + return err + } + if n == 0 { + return io.ErrShortWrite + } + chunk = chunk[n:] + } + } + return nil +} diff --git a/transport/trojan/service.go b/transport/trojan/service.go index 7f1803bb..4df21bc4 100644 --- a/transport/trojan/service.go +++ b/transport/trojan/service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/binary" "net" + "sync" "github.com/sagernet/sing/common/auth" "github.com/sagernet/sing/common/buf" @@ -26,6 +27,9 @@ type Service[K comparable] struct { handler Handler fallbackHandler N.TCPConnectionHandlerEx logger logger.ContextLogger + conns map[K][]net.Conn + + mu sync.RWMutex } func NewService[K comparable](handler Handler, fallbackHandler N.TCPConnectionHandlerEx, logger logger.ContextLogger) *Service[K] { @@ -35,6 +39,7 @@ func NewService[K comparable](handler Handler, fallbackHandler N.TCPConnectionHa handler: handler, fallbackHandler: fallbackHandler, logger: logger, + conns: make(map[K][]net.Conn), } } @@ -54,8 +59,20 @@ func (s *Service[K]) UpdateUsers(userList []K, passwordList []string) error { users[user] = key keys[key] = user } + s.mu.Lock() s.users = users s.keys = keys + var closedConns []net.Conn + for user, conns := range s.conns { + if _, exists := users[user]; !exists { + closedConns = append(closedConns, conns...) + delete(s.conns, user) + } + } + s.mu.Unlock() + for _, conn := range closedConns { + conn.Close() + } return nil } @@ -68,9 +85,30 @@ func (s *Service[K]) NewConnection(ctx context.Context, conn net.Conn, source M. return s.fallback(ctx, conn, source, key[:n], E.New("bad request size"), onClose) } + s.mu.RLock() if user, loaded := s.keys[key]; loaded { + s.mu.RUnlock() ctx = auth.ContextWithUser(ctx, user) + s.mu.Lock() + s.conns[user] = append(s.conns[user], conn) + s.mu.Unlock() + originalOnClose := onClose + onClose = N.OnceClose(func(it error) { + s.mu.Lock() + conns := s.conns[user] + for i, c := range conns { + if c == conn { + s.conns[user] = append(conns[:i], conns[i+1:]...) + break + } + } + s.mu.Unlock() + if originalOnClose != nil { + originalOnClose(it) + } + }) } else { + s.mu.RUnlock() return s.fallback(ctx, conn, source, key[:], E.New("bad request"), onClose) } diff --git a/transport/trusttunnel/client.go b/transport/trusttunnel/client.go new file mode 100644 index 00000000..b1dcc6a4 --- /dev/null +++ b/transport/trusttunnel/client.go @@ -0,0 +1,323 @@ +package trusttunnel + +import ( + "context" + stdtls "crypto/tls" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "runtime" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/bufio" + "github.com/sagernet/sing/common/ntp" + + "github.com/sagernet/quic-go" + "github.com/sagernet/quic-go/http3" + qtls "github.com/sagernet/sing-quic" + "golang.org/x/net/http2" +) + +var ( + appName = "sing-box" + appVersion = C.Version + tcpUserAgent = runtime.GOOS + " " + appName + "/" + appVersion + udpUserAgent = runtime.GOOS + " " + UDPMagicAddress + icmpUserAgent = runtime.GOOS + " " + ICMPMagicAddress +) + +type Dialer interface { + Dial(ctx context.Context, host string) (net.Conn, error) + ListenPacket(ctx context.Context) (net.PacketConn, error) + Close() error +} + +type ClientOptions struct { + TLSDialer tls.Dialer + QUICDialer N.Dialer + QUICTLSConfig tls.Config + Server M.Socksaddr + Username string + Password string + QUIC bool + CongestionControl string + CWND int + BBRProfile string + HealthCheck bool + MaxConnections int + MinStreams int + MaxStreams int +} + +type Client struct { + ctx context.Context + cancel context.CancelFunc + server M.Socksaddr + serverString string + auth string + roundTripper http.RoundTripper + startOnce sync.Once + healthCheck bool + healthCheckTimer *time.Timer + count atomic.Int64 +} + +func NewClient(ctx context.Context, options ClientOptions) (*Client, error) { + ctx, cancel := context.WithCancel(ctx) + client := &Client{ + ctx: ctx, + cancel: cancel, + server: options.Server, + serverString: options.Server.String(), + auth: buildAuth(options.Username, options.Password), + healthCheck: options.HealthCheck, + } + if options.QUIC { + congestionControlFactory, err := NewCongestionControl(options.CongestionControl, options.CWND, options.BBRProfile, ntp.TimeFuncFromContext(ctx)) + if err != nil { + cancel() + return nil, err + } + client.roundTripper = &http3.Transport{ + QUICConfig: &quic.Config{ + MaxIdleTimeout: DefaultSessionTimeout * 2, + KeepAlivePeriod: DefaultHealthCheckTimeout, + }, + Dial: func(ctx context.Context, addr string, tlsCfg *stdtls.Config, cfg *quic.Config) (*quic.Conn, error) { + udpConn, err := options.QUICDialer.DialContext(ctx, N.NetworkUDP, client.server) + if err != nil { + return nil, err + } + conn, err := qtls.DialEarly(ctx, bufio.NewUnbindPacketConn(udpConn), udpConn.RemoteAddr(), options.QUICTLSConfig, cfg) + if err != nil { + return nil, err + } + conn.SetCongestionControl(congestionControlFactory(conn)) + return conn, nil + }, + } + } else { + client.roundTripper = &http2.Transport{ + DialTLSContext: func(ctx context.Context, network, addr string, _ *stdtls.Config) (net.Conn, error) { + return options.TLSDialer.DialContext(ctx, network, client.server) + }, + AllowHTTP: true, + } + } + return client, nil +} + +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) roundTrip(request *http.Request, conn *httpConn) { + c.startOnce.Do(c.start) + pipeReader, pipeWriter := io.Pipe() + request.Body = pipeReader + *conn = httpConn{writer: pipeWriter, created: make(chan struct{})} + c.count.Add(1) + conn.closeFn = sync.OnceFunc(func() { c.count.Add(-1) }) + ctx, cancel := context.WithCancel(c.ctx) + conn.cancelFn = cancel + go func() { + timeout := time.AfterFunc(C.TCPTimeout, cancel) + defer timeout.Stop() + request = request.WithContext(ctx) + 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) newConnectRequest(host, userAgent string) *http.Request { + return &http.Request{ + Method: http.MethodConnect, + URL: &url.URL{Scheme: "https", Host: c.serverString}, + Header: http.Header{ + "User-Agent": {userAgent}, + "Proxy-Authorization": {c.auth}, + }, + Host: host, + } +} + +func (c *Client) Dial(ctx context.Context, host string) (net.Conn, error) { + conn := &tcpConn{} + c.roundTrip(c.newConnectRequest(host, tcpUserAgent), &conn.httpConn) + return conn, nil +} + +func (c *Client) ListenPacket(ctx context.Context) (net.PacketConn, error) { + conn := &clientPacketConn{} + c.roundTrip(c.newConnectRequest(UDPMagicAddress, udpUserAgent), &conn.httpConn) + return conn, nil +} + +func (c *Client) Close() error { + c.cancel() + if closer, ok := c.roundTripper.(io.Closer); ok { + _ = closer.Close() + } + if t, ok := c.roundTripper.(*http2.Transport); ok { + t.CloseIdleConnections() + } + if c.healthCheckTimer != nil { + c.healthCheckTimer.Stop() + } + return nil +} + +func (c *Client) HealthCheck(ctx context.Context) error { + defer c.resetHealthCheckTimer() + response, err := c.roundTripper.RoundTrip(c.newConnectRequest(HealthCheckMagicAddress, runtime.GOOS).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 +} + +type MultiplexClient struct { + mutex sync.Mutex + maxConnections int + minStreams int + maxStreams int + ctx context.Context + options ClientOptions + clients []*Client +} + +func NewMultiplexClient(ctx context.Context, options ClientOptions) (*MultiplexClient, error) { + maxConnections := options.MaxConnections + minStreams := options.MinStreams + maxStreams := options.MaxStreams + if maxConnections == 0 && minStreams == 0 && maxStreams == 0 { + maxConnections = 8 + minStreams = 5 + } + client, err := NewClient(ctx, options) + if err != nil { + return nil, err + } + return &MultiplexClient{ + maxConnections: maxConnections, + minStreams: minStreams, + maxStreams: maxStreams, + ctx: ctx, + options: options, + clients: []*Client{client}, + }, nil +} + +func (c *MultiplexClient) Dial(ctx context.Context, host string) (net.Conn, error) { + t, err := c.getClient() + if err != nil { + return nil, err + } + return t.Dial(ctx, host) +} + +func (c *MultiplexClient) ListenPacket(ctx context.Context) (net.PacketConn, error) { + t, err := c.getClient() + if err != nil { + return nil, err + } + return t.ListenPacket(ctx) +} + +func (c *MultiplexClient) Close() error { + c.mutex.Lock() + defer c.mutex.Unlock() + var errs []error + for _, t := range c.clients { + if err := t.Close(); err != nil { + errs = append(errs, err) + } + } + c.clients = nil + return errors.Join(errs...) +} + +func (c *MultiplexClient) getClient() (*Client, error) { + c.mutex.Lock() + defer c.mutex.Unlock() + var transport *Client + for _, t := range c.clients { + if transport == nil || t.count.Load() < transport.count.Load() { + transport = t + } + } + if transport == nil { + return c.newClientLocked() + } + numStreams := int(transport.count.Load()) + if numStreams == 0 { + return transport, nil + } + if c.maxConnections > 0 { + if len(c.clients) >= c.maxConnections || numStreams < c.minStreams { + return transport, nil + } + } else if c.maxStreams > 0 && numStreams < c.maxStreams { + return transport, nil + } + return c.newClientLocked() +} + +func (c *MultiplexClient) newClientLocked() (*Client, error) { + t, err := NewClient(c.ctx, c.options) + if err != nil { + return nil, err + } + c.clients = append(c.clients, t) + return t, nil +} diff --git a/transport/trusttunnel/icmp.go b/transport/trusttunnel/icmp.go new file mode 100644 index 00000000..dd39a838 --- /dev/null +++ b/transport/trusttunnel/icmp.go @@ -0,0 +1,62 @@ +package trusttunnel + +import ( + "encoding/binary" + "net/netip" + + "github.com/sagernet/sing/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() + must(binary.Write(request, binary.BigEndian, id)) + destinationAddress := buildPaddingIP(destination) + must1(request.Write(destinationAddress[:])) + must(binary.Write(request, binary.BigEndian, sequenceNumber)) + must(binary.Write(request, binary.BigEndian, ttl)) + must(binary.Write(request, binary.BigEndian, size)) + _, err := i.writeFlush(request.Bytes()) + return err +} + +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.FreeLen()) + if err != nil { + return + } + must(binary.Read(response, binary.BigEndian, &id)) + var sourceAddressBuffer [16]byte + must1(response.Read(sourceAddressBuffer[:])) + sourceAddress = parse16BytesIP(sourceAddressBuffer) + must(binary.Read(response, binary.BigEndian, &icmpType)) + must(binary.Read(response, binary.BigEndian, &code)) + must(binary.Read(response, binary.BigEndian, &sequenceNumber)) + return +} + +func (i *IcmpConn) Close() error { + return i.httpConn.Close() +} + +func must(err error) { + if err != nil { + panic(err) + } +} + +func must1[T any](_ T, err error) { + if err != nil { + panic(err) + } +} diff --git a/transport/trusttunnel/packet.go b/transport/trusttunnel/packet.go new file mode 100644 index 00000000..068823de --- /dev/null +++ b/transport/trusttunnel/packet.go @@ -0,0 +1,210 @@ +package trusttunnel + +import ( + "encoding/binary" + "math" + "net" + + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/rw" +) + +var ( + _ N.NetPacketConn = (*clientPacketConn)(nil) + _ N.FrontHeadroom = (*clientPacketConn)(nil) +) + +type clientPacketConn struct { + httpConn +} + +func (u *clientPacketConn) FrontHeadroom() int { + return 4 + 16 + 2 + 16 + 2 + 1 + math.MaxUint8 +} + +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.FreeLen()) + 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)) + 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") + } + 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) + header := buf.NewSize(headerLen) + defer header.Release() + common.Must(binary.Write(header, binary.BigEndian, lengthField)) + common.Must(header.WriteZeroN(16 + 2)) + 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)) + _, 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) +) + +type serverPacketConn struct { + httpConn +} + +func (u *serverPacketConn) FrontHeadroom() int { + return 4 + 16 + 2 + 16 + 2 +} + +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.FreeLen()) + if err != nil { + return + } + var length uint32 + common.Must(binary.Read(header, binary.BigEndian, &length)) + common.Must(rw.SkipN(header, 16+2)) + 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) + 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.Must(header.WriteZeroN(16 + 2)) + _, 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 +} diff --git a/transport/trusttunnel/protocol.go b/transport/trusttunnel/protocol.go new file mode 100644 index 00000000..694b3145 --- /dev/null +++ b/transport/trusttunnel/protocol.go @@ -0,0 +1,174 @@ +package trusttunnel + +import ( + "bytes" + "encoding/base64" + "io" + "net" + "net/http" + "net/netip" + "strings" + "sync" + "time" +) + +const ( + UDPMagicAddress = "_udp2" + ICMPMagicAddress = "_icmp" + HealthCheckMagicAddress = "_check" + DefaultConnectionTimeout = 30 * time.Second + DefaultHealthCheckTimeout = 7 * time.Second + DefaultSessionTimeout = 30 * time.Second +) + +func buildAuth(username string, password string) string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) +} + +func parseBasicAuth(auth string) (username, password string, ok bool) { + const prefix = "Basic " + 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, ":") + return +} + +func parse16BytesIP(buffer [16]byte) netip.Addr { + var zeroPrefix [12]byte + isIPv4 := bytes.HasPrefix(buffer[:], zeroPrefix[:]) + 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 + setupOnce sync.Once + created chan struct{} + createErr error + cancelFn func() + closeFn func() + remoteAddr net.Addr + localAddr net.Addr + deadline *time.Timer + done chan struct{} +} + +func (h *httpConn) setup(body io.ReadCloser, err error) { + h.setupOnce.Do(func() { + h.body = body + h.createErr = err + close(h.created) + }) + if h.createErr != nil && body != nil { + _ = body.Close() + } +} + +func (h *httpConn) waitCreated() error { + <-h.created + if h.body != nil { + return nil + } + return h.createErr +} + +func (h *httpConn) Close() error { + h.setup(nil, net.ErrClosed) + if closer, ok := h.writer.(io.Closer); ok { + _ = closer.Close() + } + if h.body != nil { + _ = h.body.Close() + } + if h.cancelFn != nil { + h.cancelFn() + } + if h.closeFn != nil { + h.closeFn() + } + if h.done != nil { + select { + case <-h.done: + default: + close(h.done) + } + } + return nil +} + +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) RemoteAddr() net.Addr { + if h.remoteAddr != nil { + return h.remoteAddr + } + return &net.TCPAddr{} +} + +func (h *httpConn) LocalAddr() net.Addr { + if h.localAddr != nil { + return h.localAddr + } + return &net.TCPAddr{} +} + +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 +} + +func (h *httpConn) SetReadDeadline(t time.Time) error { return h.SetDeadline(t) } +func (h *httpConn) SetWriteDeadline(t time.Time) error { return h.SetDeadline(t) } + +var _ net.Conn = (*tcpConn)(nil) + +type tcpConn struct{ httpConn } + +func (t *tcpConn) Read(b []byte) (n int, err error) { + if err = t.waitCreated(); err != nil { + return 0, err + } + return t.body.Read(b) +} + +func (t *tcpConn) Write(b []byte) (int, error) { + return t.writeFlush(b) +} diff --git a/transport/trusttunnel/quic.go b/transport/trusttunnel/quic.go new file mode 100644 index 00000000..41f1c394 --- /dev/null +++ b/transport/trusttunnel/quic.go @@ -0,0 +1,140 @@ +package trusttunnel + +import ( + "context" + "errors" + "net" + "time" + + "github.com/sagernet/quic-go" + "github.com/sagernet/quic-go/congestion" + "github.com/sagernet/quic-go/http3" + "github.com/sagernet/sing-box/common/tls" + E "github.com/sagernet/sing/common/exceptions" + qtls "github.com/sagernet/sing-quic" + "github.com/sagernet/sing-quic/congestion_bbr1" + "github.com/sagernet/sing-quic/congestion_bbr2" + congestion_meta1 "github.com/sagernet/sing-quic/congestion_meta1" + congestion_meta2 "github.com/sagernet/sing-quic/congestion_meta2" + "github.com/sagernet/sing/common/ntp" +) + +func NewCongestionControl(name string, cwnd int, bbrProfile string, timeFunc func() time.Time) (func(conn *quic.Conn) congestion.CongestionControl, error) { + if timeFunc == nil { + timeFunc = time.Now + } + if cwnd == 0 { + cwnd = 32 + } + switch name { + case "", "bbr": + return func(conn *quic.Conn) congestion.CongestionControl { + return congestion_meta2.NewBbrSender( + congestion_meta2.DefaultClock{TimeFunc: timeFunc}, + congestion.ByteCount(conn.Config().InitialPacketSize), + congestion.ByteCount(cwnd)*congestion.ByteCount(conn.Config().InitialPacketSize), + ) + }, nil + case "bbr_standard": + return func(conn *quic.Conn) congestion.CongestionControl { + return congestion_bbr1.NewBbrSender( + congestion_bbr1.DefaultClock{TimeFunc: timeFunc}, + congestion.ByteCount(conn.Config().InitialPacketSize), + congestion_bbr1.InitialCongestionWindowPackets, + congestion_bbr1.MaxCongestionWindowPackets, + ) + }, nil + case "bbr2": + return func(conn *quic.Conn) congestion.CongestionControl { + return congestion_bbr2.NewBBR2Sender( + congestion_bbr2.DefaultClock{TimeFunc: timeFunc}, + congestion.ByteCount(conn.Config().InitialPacketSize), + 0, + false, + ) + }, nil + case "bbr2_variant": + return func(conn *quic.Conn) congestion.CongestionControl { + return congestion_bbr2.NewBBR2Sender( + congestion_bbr2.DefaultClock{TimeFunc: timeFunc}, + congestion.ByteCount(conn.Config().InitialPacketSize), + 32*congestion.ByteCount(conn.Config().InitialPacketSize), + true, + ) + }, nil + case "cubic": + return func(conn *quic.Conn) congestion.CongestionControl { + return congestion_meta1.NewCubicSender( + congestion_meta1.DefaultClock{TimeFunc: timeFunc}, + congestion.ByteCount(conn.Config().InitialPacketSize), + false, + ) + }, nil + case "reno": + return func(conn *quic.Conn) congestion.CongestionControl { + return congestion_meta1.NewCubicSender( + congestion_meta1.DefaultClock{TimeFunc: timeFunc}, + congestion.ByteCount(conn.Config().InitialPacketSize), + true, + ) + }, nil + default: + return nil, E.New("unknown congestion control: ", name) + } +} + +type QUICService struct { + service *Service + h3Server *http3.Server + udpConn net.PacketConn + congestionControl string + cwnd int + bbrProfile string +} + +func NewQUICService(service *Service, congestionControl string, cwnd int, bbrProfile string) *QUICService { + return &QUICService{ + service: service, + congestionControl: congestionControl, + cwnd: cwnd, + bbrProfile: bbrProfile, + } +} + +func (s *QUICService) Start(ctx context.Context, udpConn net.PacketConn, tlsConfig tls.ServerConfig) error { + s.udpConn = udpConn + congestionControlFactory, err := NewCongestionControl(s.congestionControl, s.cwnd, s.bbrProfile, ntp.TimeFuncFromContext(ctx)) + if err != nil { + return err + } + s.h3Server = &http3.Server{ + Handler: s.service, + ConnContext: func(ctx context.Context, conn *quic.Conn) context.Context { + conn.SetCongestionControl(congestionControlFactory(conn)) + return ctx + }, + } + quicListener, err := qtls.ListenEarly(udpConn, tlsConfig, &quic.Config{ + MaxIdleTimeout: DefaultSessionTimeout * 2, + MaxIncomingStreams: 1 << 60, + Allow0RTT: true, + }) + if err != nil { + return err + } + go func() { + _ = s.h3Server.ServeListener(quicListener) + }() + return nil +} + +func (s *QUICService) Close() error { + var errs []error + if s.h3Server != nil { + errs = append(errs, s.h3Server.Close()) + } + if s.udpConn != nil { + errs = append(errs, s.udpConn.Close()) + } + return errors.Join(errs...) +} diff --git a/transport/trusttunnel/service.go b/transport/trusttunnel/service.go new file mode 100644 index 00000000..5841782a --- /dev/null +++ b/transport/trusttunnel/service.go @@ -0,0 +1,218 @@ +package trusttunnel + +import ( + "context" + "io" + "net" + "net/http" + "sync" + + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +type Handler interface { + N.TCPConnectionHandler + N.UDPConnectionHandler +} + +type ServiceOptions struct { + Ctx context.Context + Logger logger.ContextLogger + Handler Handler +} + +type Service struct { + ctx context.Context + logger logger.ContextLogger + users map[string]string + handler Handler + conns map[string][]io.Closer + + mu sync.RWMutex +} + +func NewService(options ServiceOptions) *Service { + return &Service{ + ctx: options.Ctx, + logger: options.Logger, + handler: options.Handler, + conns: make(map[string][]io.Closer), + } +} + +func (s *Service) UpdateUsers(users map[string]string) { + s.mu.Lock() + s.users = users + var closedConns []io.Closer + for user, conns := range s.conns { + if _, exists := users[user]; !exists { + closedConns = append(closedConns, conns...) + delete(s.conns, user) + } + } + s.mu.Unlock() + for _, conn := range closedConns { + conn.Close() + } +} + +func (s *Service) trackConn(username string, conn io.Closer) { + s.mu.Lock() + s.conns[username] = append(s.conns[username], conn) + s.mu.Unlock() +} + +func (s *Service) untrackConn(username string, conn io.Closer) { + s.mu.Lock() + conns := s.conns[username] + for i, c := range conns { + if c == conn { + s.conns[username] = append(conns[:i], conns[i+1:]...) + break + } + } + s.mu.Unlock() +} + +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) + switch request.Host { + case UDPMagicAddress: + writer.WriteHeader(http.StatusOK) + flusher, isFlusher := writer.(http.Flusher) + if isFlusher { + flusher.Flush() + } + done := make(chan struct{}) + conn := &serverPacketConn{ + httpConn: httpConn{ + writer: writer, + flusher: flusher, + created: make(chan struct{}), + done: done, + remoteAddr: parseRemoteAddr(request.RemoteAddr), + }, + } + 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 from ", request.RemoteAddr)) + return + } + destination = destination.Unwrap() + cachedConn := bufio.NewCachedPacketConn(conn, firstPacket, destination) + s.trackConn(username, conn) + _ = s.handler.NewPacketConnection(ctx, cachedConn, M.Metadata{ + Protocol: "trusttunnel", + Source: M.ParseSocksaddr(request.RemoteAddr), + Destination: destination, + }) + <-done + s.untrackConn(username, 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() + } + done := make(chan struct{}) + conn := &tcpConn{ + httpConn{ + writer: writer, + flusher: flusher, + created: make(chan struct{}), + done: done, + remoteAddr: parseRemoteAddr(request.RemoteAddr), + }, + } + conn.setup(request.Body, nil) + wrapper := &h2ConnWrapper{Conn: conn} + s.trackConn(username, wrapper) + _ = s.handler.NewConnection(ctx, wrapper, M.Metadata{ + Protocol: "trusttunnel", + Source: M.ParseSocksaddr(request.RemoteAddr), + Destination: M.ParseSocksaddr(request.Host).Unwrap(), + }) + <-done + s.untrackConn(username, wrapper) + wrapper.CloseWrapper() + } +} + +func (s *Service) verify(authorization string) (username string, loaded bool) { + username, password, loaded := parseBasicAuth(authorization) + if !loaded { + return "", false + } + s.mu.RLock() + recordedPassword, loaded := s.users[username] + s.mu.RUnlock() + 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)) +} + +func parseRemoteAddr(addr string) net.Addr { + tcpAddr, err := net.ResolveTCPAddr("tcp", addr) + if err != nil { + return nil + } + return tcpAddr +} + +type h2ConnWrapper struct { + net.Conn + access sync.Mutex + closed bool +} + +func (w *h2ConnWrapper) Write(p []byte) (n int, err error) { + w.access.Lock() + defer w.access.Unlock() + if w.closed { + return 0, net.ErrClosed + } + return w.Conn.Write(p) +} + +func (w *h2ConnWrapper) CloseWrapper() { + w.access.Lock() + defer w.access.Unlock() + w.closed = true +}