diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7bd16108..5f60b4d6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -350,7 +350,7 @@ jobs: mkdir clients/android/app/libs cp libbox.aar clients/android/app/libs cd clients/android - ./gradlew :app:assemblePlayRelease :app:assembleOtherRelease + ./gradlew :app:assemblePlayRelease env: JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64 ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 6ee53c5c..953e7325 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -31,6 +31,54 @@ builds: - linux_arm_7 - linux_s390x - linux_riscv64 + - linux_mips + - linux_mips_softfloat + - linux_mipsle + - linux_mipsle_softfloat + - linux_mips64 + - linux_mips64le + - windows_amd64_v1 + - windows_386 + - windows_arm64 + - darwin_amd64_v1 + - darwin_arm64 + mod_timestamp: '{{ .CommitTimestamp }}' + - id: manager + main: ./cmd/sing-box + flags: + - -v + - -trimpath + ldflags: + - -X github.com/sagernet/sing-box/constant.Version={{ .Version }} + - -s + - -buildid= + tags: + - with_gvisor + - with_quic + - with_dhcp + - with_wireguard + - with_utls + - with_acme + - with_clash_api + - with_tailscale + - with_manager + - with_admin_panel + env: + - CGO_ENABLED=0 + - GOTOOLCHAIN=local + targets: + - linux_386 + - linux_amd64_v1 + - linux_arm64 + - linux_arm_6 + - linux_arm_7 + - linux_s390x + - linux_riscv64 + - linux_mips + - linux_mips_softfloat + - linux_mipsle + - linux_mipsle_softfloat + - linux_mips64 - linux_mips64le - windows_amd64_v1 - windows_386 @@ -51,8 +99,6 @@ builds: - with_tailscale env: - CGO_ENABLED=0 - - GOROOT={{ .Env.GOPATH }}/go_legacy - tool: "{{ .Env.GOPATH }}/go_legacy/bin/go" targets: - windows_amd64_v1 - windows_386 @@ -104,91 +150,25 @@ archives: wrap_in_directory: true 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 }}' + 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_with_manager + builds: + - manager + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + wrap_in_directory: true + files: + - LICENSE + name_template: '{{ .ProjectName }}-{{ .Version }}-with-manager-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if and .Mips (not (eq .Mips "hardfloat")) }}-{{ .Mips }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' - id: archive-legacy <<: *template builds: - legacy name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}-legacy' -nfpms: - - id: package - package_name: sing-box - file_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 }}' - builds: - - main - homepage: https://sing-box.sagernet.org/ - maintainer: nekohasekai - description: The universal proxy platform. - license: GPLv3 or later - formats: - - deb - - rpm - - archlinux -# - apk -# - ipk - priority: extra - contents: - - src: release/config/config.json - dst: /etc/sing-box/config.json - type: "config|noreplace" - - - src: release/config/sing-box.service - dst: /usr/lib/systemd/system/sing-box.service - - src: release/config/sing-box@.service - dst: /usr/lib/systemd/system/sing-box@.service - - src: release/config/sing-box.sysusers - dst: /usr/lib/sysusers.d/sing-box.conf - - src: release/config/sing-box.rules - dst: /usr/share/polkit-1/rules.d/sing-box.rules - - src: release/config/sing-box-split-dns.xml - dst: /usr/share/dbus-1/system.d/sing-box-split-dns.conf - - - src: release/completions/sing-box.bash - dst: /usr/share/bash-completion/completions/sing-box.bash - - src: release/completions/sing-box.fish - dst: /usr/share/fish/vendor_completions.d/sing-box.fish - - src: release/completions/sing-box.zsh - dst: /usr/share/zsh/site-functions/_sing-box - - - src: LICENSE - dst: /usr/share/licenses/sing-box/LICENSE - deb: - signature: - key_file: "{{ .Env.NFPM_KEY_PATH }}" - fields: - Bugs: https://github.com/SagerNet/sing-box/issues - rpm: - signature: - key_file: "{{ .Env.NFPM_KEY_PATH }}" - overrides: - apk: - contents: - - src: release/config/config.json - dst: /etc/sing-box/config.json - type: config - - - src: release/config/sing-box.initd - dst: /etc/init.d/sing-box - - - src: release/completions/sing-box.bash - dst: /usr/share/bash-completion/completions/sing-box.bash - - src: release/completions/sing-box.fish - dst: /usr/share/fish/vendor_completions.d/sing-box.fish - - src: release/completions/sing-box.zsh - dst: /usr/share/zsh/site-functions/_sing-box - - - src: LICENSE - dst: /usr/share/licenses/sing-box/LICENSE - ipk: - contents: - - src: release/config/config.json - dst: /etc/sing-box/config.json - type: config - - - src: release/config/openwrt.init - dst: /etc/init.d/sing-box - - src: release/config/openwrt.conf - dst: /etc/config/sing-box source: enabled: false name_template: '{{ .ProjectName }}-{{ .Version }}.source' @@ -200,8 +180,8 @@ signs: - artifacts: checksum release: github: - owner: SagerNet - name: sing-box + owner: shtorm-7 + name: sing-box-extended draft: true prerelease: auto mode: replace @@ -209,5 +189,3 @@ release: - archive - package skip_upload: true -partial: - by: target \ No newline at end of file diff --git a/Makefile b/Makefile index 0f78baaf..387617b8 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ NAME = sing-box COMMIT = $(shell git rev-parse --short HEAD) -TAGS ?= with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale +TAGS ?= with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_manager,with_admin_panel GOHOSTOS = $(shell go env GOHOSTOS) GOHOSTARCH = $(shell go env GOHOSTARCH) @@ -64,14 +64,10 @@ update_certificates: go run ./cmd/internal/update_certificates release: - go run ./cmd/internal/build goreleaser release --clean --skip publish + go run ./cmd/internal/build goreleaser release --skip=validate --clean -p 3 --skip publish mkdir dist/release mv dist/*.tar.gz \ dist/*.zip \ - dist/*.deb \ - dist/*.rpm \ - dist/*_amd64.pkg.tar.zst \ - dist/*_arm64.pkg.tar.zst \ dist/release ghr --replace --draft --prerelease -p 5 "v${VERSION}" dist/release rm -r dist/release @@ -86,7 +82,7 @@ update_android_version: go run ./cmd/internal/update_android_version build_android: - cd ../sing-box-for-android && ./gradlew :app:clean :app:assemblePlayRelease :app:assembleOtherRelease && ./gradlew --stop + cd ../sing-box-for-android && ./gradlew :app:clean :app:assemblePlayRelease && ./gradlew --stop upload_android: mkdir -p dist/release_android @@ -95,7 +91,7 @@ upload_android: ghr --replace --draft --prerelease -p 5 "v${VERSION}" dist/release_android rm -rf dist/release_android -release_android: lib_android update_android_version build_android upload_android +release_android: lib_android update_android_version build_android publish_android: cd ../sing-box-for-android && ./gradlew :app:publishPlayReleaseBundle && ./gradlew --stop diff --git a/adapter/inbound.go b/adapter/inbound.go index 98a152c7..5e74ca1b 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -31,6 +31,7 @@ type UDPInjectableInbound interface { type InboundRegistry interface { option.InboundOptionsRegistry Create(ctx context.Context, router Router, logger log.ContextLogger, tag string, inboundType string, options any) (Inbound, error) + UnsafeCreate(ctx context.Context, router Router, logger log.ContextLogger, tag string, inboundType string, options any) (Inbound, error) } type InboundManager interface { diff --git a/adapter/inbound/registry.go b/adapter/inbound/registry.go index 01e367d8..2297ddb8 100644 --- a/adapter/inbound/registry.go +++ b/adapter/inbound/registry.go @@ -57,6 +57,10 @@ func (m *Registry) CreateOptions(outboundType string) (any, bool) { func (m *Registry) Create(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, outboundType string, options any) (adapter.Inbound, error) { m.access.Lock() defer m.access.Unlock() + return m.UnsafeCreate(ctx, router, logger, tag, outboundType, options) +} + +func (m *Registry) UnsafeCreate(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, outboundType string, options any) (adapter.Inbound, error) { constructor, loaded := m.constructor[outboundType] if !loaded { return nil, E.New("outbound type not found: " + outboundType) diff --git a/adapter/outbound.go b/adapter/outbound.go index 2c2b1091..65edd654 100644 --- a/adapter/outbound.go +++ b/adapter/outbound.go @@ -21,6 +21,7 @@ type Outbound interface { type OutboundRegistry interface { option.OutboundOptionsRegistry CreateOutbound(ctx context.Context, router Router, logger log.ContextLogger, tag string, outboundType string, options any) (Outbound, error) + UnsafeCreateOutbound(ctx context.Context, router Router, logger log.ContextLogger, tag string, outboundType string, options any) (Outbound, error) } type OutboundManager interface { diff --git a/adapter/outbound/registry.go b/adapter/outbound/registry.go index 8743ba10..f9c2dc59 100644 --- a/adapter/outbound/registry.go +++ b/adapter/outbound/registry.go @@ -57,6 +57,10 @@ func (r *Registry) CreateOptions(outboundType string) (any, bool) { func (r *Registry) CreateOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, outboundType string, options any) (adapter.Outbound, error) { r.access.Lock() defer r.access.Unlock() + return r.UnsafeCreateOutbound(ctx, router, logger, tag, outboundType, options) +} + +func (r *Registry) UnsafeCreateOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, outboundType string, options any) (adapter.Outbound, error) { constructor, loaded := r.constructors[outboundType] if !loaded { return nil, E.New("outbound type not found: " + outboundType) diff --git a/box.go b/box.go index 9f1e9512..fd12fd4c 100644 --- a/box.go +++ b/box.go @@ -159,6 +159,7 @@ func New(options Options) (*Box, error) { if err != nil { return nil, E.Cause(err, "create log factory") } + service.MustRegister[log.Factory](ctx, logFactory) var internalServices []adapter.LifecycleService certificateOptions := common.PtrValueOrDefault(options.Certificate) diff --git a/cmd/sing-box/cmd_tools_fetch_http3.go b/cmd/sing-box/cmd_tools_fetch_http3.go index b7a31a72..6cc4fcc0 100644 --- a/cmd/sing-box/cmd_tools_fetch_http3.go +++ b/cmd/sing-box/cmd_tools_fetch_http3.go @@ -1,5 +1,3 @@ -//go:build with_quic - package main import ( diff --git a/common/mux/router.go b/common/mux/router.go index ec788086..d9e80407 100644 --- a/common/mux/router.go +++ b/common/mux/router.go @@ -38,6 +38,9 @@ func NewRouterWithOptions(router adapter.ConnectionRouterEx, logger logger.Conte } } service, err := mux.NewService(mux.ServiceOptions{ + NewConnectionContext: func(ctx context.Context, conn net.Conn) context.Context { + return log.ContextWithNewMuxID(ctx) + }, NewStreamContext: func(ctx context.Context, conn net.Conn) context.Context { return log.ContextWithNewID(ctx) }, diff --git a/constant/proxy.go b/constant/proxy.go index e7785107..59bf293b 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -1,40 +1,50 @@ package constant const ( - TypeTun = "tun" - TypeRedirect = "redirect" - TypeTProxy = "tproxy" - TypeDirect = "direct" - TypeBlock = "block" - TypeDNS = "dns" - TypeSOCKS = "socks" - TypeHTTP = "http" - TypeMixed = "mixed" - TypeShadowsocks = "shadowsocks" - TypeVMess = "vmess" - TypeTrojan = "trojan" - TypeNaive = "naive" - TypeWireGuard = "wireguard" - TypeWARP = "warp" - TypeHysteria = "hysteria" - TypeTor = "tor" - TypeSSH = "ssh" - TypeShadowTLS = "shadowtls" - TypeMieru = "mieru" - TypeAnyTLS = "anytls" - TypeShadowsocksR = "shadowsocksr" - TypeVLESS = "vless" - TypeTUIC = "tuic" - TypeHysteria2 = "hysteria2" - TypeTunnelClient = "tunnel_client" - TypeTunnelServer = "tunnel_server" - TypeTailscale = "tailscale" - TypeDERP = "derp" - TypeResolved = "resolved" - TypeSSMAPI = "ssm-api" + TypeTun = "tun" + TypeRedirect = "redirect" + TypeTProxy = "tproxy" + TypeDirect = "direct" + TypeBlock = "block" + TypeDNS = "dns" + TypeSOCKS = "socks" + TypeHTTP = "http" + TypeMixed = "mixed" + TypeShadowsocks = "shadowsocks" + TypeVMess = "vmess" + TypeTrojan = "trojan" + TypeNaive = "naive" + TypeWireGuard = "wireguard" + TypeWARP = "warp" + TypeHysteria = "hysteria" + TypeTor = "tor" + TypeSSH = "ssh" + TypeShadowTLS = "shadowtls" + TypeMieru = "mieru" + TypeAnyTLS = "anytls" + TypeShadowsocksR = "shadowsocksr" + TypeVLESS = "vless" + TypeTUIC = "tuic" + TypeHysteria2 = "hysteria2" + TypeBond = "bond" + TypeTunnelServer = "tunnel-server" + TypeTunnelClient = "tunnel-client" + TypeTailscale = "tailscale" + TypeConnectionLimiter = "connection-limiter" + TypeBandwidthLimiter = "bandwidth-limiter" + TypeTrafficLimiter = "traffic-limiter" + TypeAdminPanel = "admin-panel" + TypeNodeManagerServer = "node-manager-server" + TypeNodeManagerClient = "node-manager-client" + TypeDERP = "derp" + TypeManager = "manager" + TypeNode = "node" + TypeResolved = "resolved" + TypeSSMAPI = "ssm-api" ) const ( + TypeFailover = "failover" TypeSelector = "selector" TypeURLTest = "urltest" ) diff --git a/constant/v2ray.go b/constant/v2ray.go index 1811df5f..17443a70 100644 --- a/constant/v2ray.go +++ b/constant/v2ray.go @@ -7,4 +7,5 @@ const ( V2RayTransportTypeGRPC = "grpc" V2RayTransportTypeHTTPUpgrade = "httpupgrade" V2RayTransportTypeXHTTP = "xhttp" + V2RayTransportTypeKCP = "mkcp" ) diff --git a/examples/tunnel/client->server/client.json b/examples/tunnel/client->server/client.json index c2571a47..91941a82 100644 --- a/examples/tunnel/client->server/client.json +++ b/examples/tunnel/client->server/client.json @@ -1,6 +1,6 @@ { "log": { - "level": "error" + "level": "info" }, "dns": { "servers": [ @@ -12,7 +12,7 @@ }, "endpoints": [ { - "type": "tunnel_client", + "type": "tunnel-client", "tag": "tunnel", "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe", @@ -30,7 +30,7 @@ { "type": "mixed", "tag": "mixed-in", - "listen_port": 7897 + "listen_port": 10000 } ], "outbounds": [ @@ -41,16 +41,22 @@ { "type": "dns", "tag": "dns-out" + }, + { + "type": "failover", + "tag": "f", + "outbounds": ["tunnel", "direct-out"], + "interrupt_exist_connections": false, } ], "route": { "rules": [ { - "outbound": "tunnel", + "outbound": "f", "override_tunnel_destination": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13" } ], - "final": "direct-out", + "final": "f", "default_domain_resolver": "default", "auto_detect_interface": true } diff --git a/examples/tunnel/client->server/server.json b/examples/tunnel/client->server/server.json index 0a74beb8..282a2f37 100644 --- a/examples/tunnel/client->server/server.json +++ b/examples/tunnel/client->server/server.json @@ -1,6 +1,6 @@ { "log": { - "level": "error" + "level": "info" }, "dns": { "servers": [ @@ -12,7 +12,7 @@ }, "endpoints": [ { - "type": "tunnel_server", + "type": "tunnel-server", "tag": "tunnel", "uuid": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13", "users": [ diff --git a/examples/tunnel/client1->server->client2/client1.json b/examples/tunnel/client1->server->client2/client1.json index 09c73242..29b72784 100644 --- a/examples/tunnel/client1->server->client2/client1.json +++ b/examples/tunnel/client1->server->client2/client1.json @@ -12,7 +12,7 @@ }, "endpoints": [ { - "type": "tunnel_client", + "type": "tunnel-client", "tag": "tunnel", "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe", diff --git a/examples/tunnel/client1->server->client2/client2.json b/examples/tunnel/client1->server->client2/client2.json index 7dda19a3..ef13a2c1 100644 --- a/examples/tunnel/client1->server->client2/client2.json +++ b/examples/tunnel/client1->server->client2/client2.json @@ -12,7 +12,7 @@ }, "endpoints": [ { - "type": "tunnel_client", + "type": "tunnel-client", "tag": "tunnel", "uuid": "487f6073-3300-4819-a07d-39652e45fb4d", "key": "3d74d616-2502-4c17-9cc3-92c366550f4f", diff --git a/examples/tunnel/client1->server->client2/server.json b/examples/tunnel/client1->server->client2/server.json index 9110ca62..0a83bd8f 100644 --- a/examples/tunnel/client1->server->client2/server.json +++ b/examples/tunnel/client1->server->client2/server.json @@ -12,7 +12,7 @@ }, "endpoints": [ { - "type": "tunnel_server", + "type": "tunnel-server", "tag": "tunnel", "uuid": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13", "users": [ diff --git a/examples/tunnel/proxy_client->server->tunnel_client/server.json b/examples/tunnel/proxy_client->server->tunnel_client/server.json index 2984260e..6efc6efb 100644 --- a/examples/tunnel/proxy_client->server->tunnel_client/server.json +++ b/examples/tunnel/proxy_client->server->tunnel_client/server.json @@ -12,7 +12,7 @@ }, "endpoints": [ { - "type": "tunnel_server", + "type": "tunnel-server", "tag": "tunnel", "uuid": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13", "users": [ diff --git a/examples/tunnel/proxy_client->server->tunnel_client/tunnel_client.json b/examples/tunnel/proxy_client->server->tunnel_client/tunnel_client.json index 48316a46..d3d9d7d5 100644 --- a/examples/tunnel/proxy_client->server->tunnel_client/tunnel_client.json +++ b/examples/tunnel/proxy_client->server->tunnel_client/tunnel_client.json @@ -12,7 +12,7 @@ }, "endpoints": [ { - "type": "tunnel_client", + "type": "tunnel-client", "tag": "tunnel", "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe", diff --git a/examples/tunnel/server->client/client.json b/examples/tunnel/server->client/client.json index 48316a46..95b146f8 100644 --- a/examples/tunnel/server->client/client.json +++ b/examples/tunnel/server->client/client.json @@ -1,6 +1,6 @@ { "log": { - "level": "error" + "level": "info" }, "dns": { "servers": [ @@ -12,7 +12,7 @@ }, "endpoints": [ { - "type": "tunnel_client", + "type": "tunnel-client", "tag": "tunnel", "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe", diff --git a/examples/tunnel/server->client/server.json b/examples/tunnel/server->client/server.json index ae005cad..52a26613 100644 --- a/examples/tunnel/server->client/server.json +++ b/examples/tunnel/server->client/server.json @@ -1,6 +1,6 @@ { "log": { - "level": "error" + "level": "info" }, "dns": { "servers": [ @@ -12,7 +12,7 @@ }, "endpoints": [ { - "type": "tunnel_server", + "type": "tunnel-server", "tag": "tunnel", "uuid": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13", "users": [ @@ -39,7 +39,7 @@ { "type": "mixed", "tag": "mixed-in", - "listen_port": 7897 + "listen_port": 10000 } ], "outbounds": [ diff --git a/go.mod b/go.mod index 796b3df8..d9066812 100644 --- a/go.mod +++ b/go.mod @@ -1,20 +1,25 @@ module github.com/sagernet/sing-box -go 1.24.4 - -toolchain go1.24.6 +go 1.25 require ( + github.com/GoAdminGroup/go-admin v1.2.26 + github.com/GoAdminGroup/themes v0.0.48 github.com/anytls/sing-anytls v0.0.11 github.com/caddyserver/certmagic v0.23.0 github.com/coder/websocket v1.8.13 github.com/cretz/bine v0.2.0 github.com/enfein/mieru/v3 v3.17.1 + github.com/go-chi/chi v1.5.5 github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/render v1.0.3 + github.com/go-playground/validator/v10 v10.30.1 github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 github.com/gofrs/uuid/v5 v5.3.2 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/huandu/go-sqlbuilder v1.38.1 github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f + github.com/lib/pq v1.10.9 github.com/libdns/alidns v1.0.5-libdns.v1.beta1 github.com/libdns/cloudflare v0.2.2-0.20250708034226-c574dccb31a6 github.com/logrusorgru/aurora v2.0.3+incompatible @@ -30,7 +35,7 @@ require ( github.com/sagernet/gomobile v0.1.8 github.com/sagernet/gvisor v0.0.0-20250325023245-7a9c0f5725fb github.com/sagernet/quic-go v0.52.0-sing-box-mod.3 - github.com/sagernet/sing v0.7.13 + github.com/sagernet/sing v0.7.14 github.com/sagernet/sing-mux v0.3.3 github.com/sagernet/sing-quic v0.5.2-0.20250909083218-00a55617c0fb github.com/sagernet/sing-shadowsocks v0.2.8 @@ -38,47 +43,59 @@ require ( github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 github.com/sagernet/sing-tun v0.7.3 github.com/sagernet/sing-vmess v0.2.7 - github.com/sagernet/smux v1.5.34-mod.2 + github.com/sagernet/smux v1.5.50-sing-box-mod.1 github.com/sagernet/tailscale v1.80.3-sing-box-1.12-mod.2 github.com/sagernet/wireguard-go v0.0.1-beta.7 github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 github.com/spf13/cobra v1.9.1 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 github.com/vishvananda/netns v0.0.5 go.uber.org/zap v1.27.0 go4.org/netipx v0.0.0-20231129151722-fdeea329fbba - golang.org/x/crypto v0.41.0 - golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 - golang.org/x/mod v0.27.0 - golang.org/x/net v0.43.0 - golang.org/x/sys v0.35.0 - golang.org/x/time v0.11.0 + golang.org/x/crypto v0.47.0 + golang.org/x/exp v0.0.0-20260112195511-716be5621a96 + golang.org/x/mod v0.32.0 + golang.org/x/net v0.49.0 + golang.org/x/sys v0.40.0 + golang.org/x/time v0.12.0 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 - google.golang.org/grpc v1.73.0 - google.golang.org/protobuf v1.36.6 + google.golang.org/grpc v1.78.0 + google.golang.org/protobuf v1.36.11 howett.net/plist v1.0.1 ) +require ( + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect + github.com/nxadm/tail v1.4.11 // indirect + github.com/zeebo/assert v1.3.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect +) + require ( github.com/AdguardTeam/golibs v0.32.7 // indirect github.com/ameshkov/dnscrypt/v2 v2.4.0 github.com/ameshkov/dnsstamps v1.0.3 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/text v0.28.0 // indirect - golang.org/x/tools v0.36.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/tools v0.41.0 // indirect ) //replace github.com/sagernet/sing => ../sing require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/360EntSecGroup-Skylar/excelize v1.4.1 // indirect + github.com/GoAdminGroup/html v0.0.1 // indirect + github.com/NebulousLabs/fastrand v0.0.0-20181203155948-6fb6489aac4e // 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/andybalholm/brotli v1.2.0 // indirect github.com/bits-and-blooms/bitset v1.13.0 // indirect github.com/caddyserver/zerossl v0.1.3 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -89,12 +106,19 @@ require ( github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect github.com/fsnotify/fsnotify v1.7.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.11.1 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-gonic/gin v1.3.0 // indirect github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 // 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/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/golang/snappy v1.0.0 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect @@ -104,19 +128,31 @@ require ( github.com/gorilla/securecookie v1.1.2 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/huandu/go-clone v1.7.3 // indirect + github.com/huandu/xstrings v1.4.0 // indirect github.com/illarion/gonotify/v2 v2.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // 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 github.com/jsimonetti/rtnetlink v1.4.0 // indirect - github.com/klauspost/compress v1.17.11 // indirect - github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.3 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/libdns/libdns v1.1.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect github.com/mdlayher/sdnotify v1.0.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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/pierrec/lz4/v4 v4.1.25 // 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.5.1 // indirect @@ -124,6 +160,7 @@ require ( github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect github.com/sagernet/nftables v0.3.0-beta.4 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/syndtr/goleveldb v1.0.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/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 // indirect @@ -134,24 +171,33 @@ require ( github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect github.com/tevino/abool v1.2.0 // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/blake3 v0.2.4 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap/exp v0.3.0 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect - golang.org/x/term v0.34.0 // indirect + golang.org/x/term v0.39.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect + gopkg.in/go-playground/validator.v8 v8.18.2 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect + xorm.io/builder v0.3.7 // indirect + xorm.io/xorm v1.0.2 // indirect ) replace github.com/sagernet/wireguard-go => github.com/shtorm-7/wireguard-go v0.0.1-beta.7-extended-1.1.0 replace github.com/sagernet/tailscale => github.com/shtorm-7/tailscale v1.80.3-sing-box-1.12-mod.2-extended-1.0.0 +replace github.com/sagernet/sing-mux => github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0 + replace github.com/sagernet/sing-dns => github.com/shtorm-7/sing-dns v0.4.6-extended-1.0.0 replace github.com/ameshkov/dnscrypt/v2 => github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0 diff --git a/go.sum b/go.sum index eeff4bfb..d961998d 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,45 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s= +gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU= +github.com/360EntSecGroup-Skylar/excelize v1.4.1 h1:l55mJb6rkkaUzOpSsgEeKYtS6/0gHwBYyfo5Jcjv/Ks= +github.com/360EntSecGroup-Skylar/excelize v1.4.1/go.mod h1:vnax29X2usfl7HHkBrX5EvSCJcmH3dT9luvxzu8iGAE= github.com/AdguardTeam/golibs v0.32.7 h1:3dmGlAVgmvquCCwHsvEl58KKcRAK3z1UnjMnwSIeDH4= github.com/AdguardTeam/golibs v0.32.7/go.mod h1:bE8KV1zqTzgZjmjFyBJ9f9O5DEKO717r7e57j1HclJA= +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/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/GoAdminGroup/go-admin v1.2.26 h1:kk18rVrteLcrzH7iMM5p/13jghDC5n3DJG/7zAnbnEU= +github.com/GoAdminGroup/go-admin v1.2.26/go.mod h1:QXj94ZrDclKzqwZnAGUWaK3qY1Wfr6/Qy5GnRGeXR+k= +github.com/GoAdminGroup/html v0.0.1 h1:SdWNWl4OKPsvDk2GDp5ZKD6ceWoN8n4Pj6cUYxavUd0= +github.com/GoAdminGroup/html v0.0.1/go.mod h1:A1laTJaOx8sQ64p2dE8IqtstDeCNBHEazrEp7hR5VvM= +github.com/GoAdminGroup/themes v0.0.48 h1:OveEEoFBCBTU5kNicqnvs0e/pL6uZKNQU1RAP9kmNFA= +github.com/GoAdminGroup/themes v0.0.48/go.mod h1:w/5P0WCmM8iv7DYE5scIT8AODYMoo6zj/bVlzAbgOaU= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/NebulousLabs/fastrand v0.0.0-20181203155948-6fb6489aac4e h1:n+DcnTNkQnHlwpsrHoQtkrJIO7CBx029fw6oR4vIob4= +github.com/NebulousLabs/fastrand v0.0.0-20181203155948-6fb6489aac4e/go.mod h1:Bdzq+51GR4/0DIhaICZEOm+OHvXGwwB2trKZ8B4Y6eQ= +github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= +github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= 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/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 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/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/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc= github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8= +github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE= github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/caddyserver/certmagic v0.23.0 h1:CfpZ/50jMfG4+1J/u2LV6piJq4HOfO6ppOnOf7DkFEU= @@ -24,11 +50,17 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +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/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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cretz/bine v0.2.0 h1:8GiDRGlTgz+o8H9DSnsl+5MeBK4HsExxgl6WgzOCuZo= github.com/cretz/bine v0.2.0/go.mod h1:WU4o9QR9wWp8AVKtTM1XD5vUHkEqnf2vVSo6dBqbetI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -37,32 +69,74 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 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/denisenkom/go-mssqldb v0.0.0-20190707035753-2be1aa521ff4/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM= +github.com/denisenkom/go-mssqldb v0.0.0-20200206145737-bbfc9a55622e h1:LzwWXEScfcTu7vUZNlDDWDARoSGEtvlDKK2BYHowNeE= +github.com/denisenkom/go-mssqldb v0.0.0-20200206145737-bbfc9a55622e/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 h1:CaO/zOnF8VvUfEbhRatPcwKVWamvbYd8tQGRWacE9kU= github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= +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/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/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/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= +github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= +github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= 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/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= 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/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.11.1 h1:5Uv5XwsaFBRo4E5VBcb9TzY8B7zxFf+U7isDxqOrRfc= github.com/gaissmai/bart v0.11.1/go.mod h1:KHeYECXQiBjTzQz/om2tqn3sZF1J7hw9m6z41ftj3fg= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.3.0 h1:kCmZyPklC0gVdL728E6Aj20uYBJV93nj/TkwBTKhFbs= +github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y= 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 v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= +github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 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-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 h1:KbX3Z3CgiYlbaavUq3Cj9/MjpO+88S7/AGXzynVDv84= github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288/go.mod h1:BWmvoE1Xia34f3l/ibJweyhrT+aROb/FQ6d+37F0e2s= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 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/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 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= @@ -71,46 +145,107 @@ github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0= github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +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-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 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/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 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/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= 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-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 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/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 h1:fiJdrgVBkjZ5B1HJ2WQwNOaXB+QyYcNXTA3t1XYLz0M= github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= +github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 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/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +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.38.1 h1:kajV1CFJQIrJgyTONhQFheJLRFnwDmTnU6e3CfFP5GQ= +github.com/huandu/go-sqlbuilder v1.38.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/illarion/gonotify/v2 v2.0.3 h1:B6+SKPo/0Sw8cRJh1aLzNEeNVFfzE3c6N+o+vyxM+9A= github.com/illarion/gonotify/v2 v2.0.3/go.mod h1:38oIJTgFqupkEydkkClkbL6i5lXV/bxdH9do5TALPEE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f h1:dd33oobuIv9PcBVqvbEiCXEbNTomOHyj3WFuC5YiPRU= github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f/go.mod h1:zhFlBeJssZ1YBCMZ5Lzu1pX4vhftDvU10WUVb1uXKtM= +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/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= -github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= -github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +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/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +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/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +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/alidns v1.0.5-libdns.v1.beta1 h1:txHK7UxDed3WFBDjrTZPuMn8X+WmhjBTTAMW5xdy5pQ= github.com/libdns/alidns v1.0.5-libdns.v1.beta1/go.mod h1:ystHmPwcGoWjPrGpensQSMY9VoCx4cpR2hXNlwk9H/g= github.com/libdns/cloudflare v0.2.2-0.20250708034226-c574dccb31a6 h1:3MGrVWs2COjMkQR17oUw1zMIPbm2YAzxDC3oGVZvQs8= @@ -120,6 +255,14 @@ github.com/libdns/libdns v1.1.0 h1:9ze/tWvt7Df6sbhOJRB8jT33GHEHpEQXdtkE3hPthbU= github.com/libdns/libdns v1.1.0/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/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= +github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +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/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= +github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= @@ -138,19 +281,65 @@ github.com/miekg/dns v1.1.67 h1:kg0EHj0G4bfT5/oOys6HhZw4vmMlnoZ+gDu8tJ/AlI0= github.com/miekg/dns v1.1.67/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= 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/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 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/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= +github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.15.0 h1:WjP/FQ/sk43MRmnEcT+MlDw2TFvkrXlprrPST/IudjU= +github.com/onsi/gomega v1.15.0/go.mod h1:cIuvLEne0aoVhAgh/O6ac0Op8WWw9H6eYCriF+tEHG0= +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/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= 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/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/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 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +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/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= @@ -172,11 +361,8 @@ github.com/sagernet/nftables v0.3.0-beta.4 h1:kbULlAwAC3jvdGAC1P5Fa3GSxVwQJibNen github.com/sagernet/nftables v0.3.0-beta.4/go.mod h1:OQXAjvjNGGFxaTgVCSTRIhYB5/llyVDeapVoENYBDS8= github.com/sagernet/quic-go v0.52.0-sing-box-mod.3 h1:ySqffGm82rPqI1TUPqmtHIYd12pfEGScygnOxjTL56w= github.com/sagernet/quic-go v0.52.0-sing-box-mod.3/go.mod h1:OV+V5kEBb8kJS7k29MzDu6oj9GyMc7HA07sE1tedxz4= -github.com/sagernet/sing v0.6.9/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= -github.com/sagernet/sing v0.7.13 h1:XNYgd8e3cxMULs/LLJspdn/deHrnPWyrrglNHeCUAYM= -github.com/sagernet/sing v0.7.13/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= -github.com/sagernet/sing-mux v0.3.3 h1:YFgt9plMWzH994BMZLmyKL37PdIVaIilwP0Jg+EcLfw= -github.com/sagernet/sing-mux v0.3.3/go.mod h1:pht8iFY4c9Xltj7rhVd208npkNaeCxzyXCgulDPLUDA= +github.com/sagernet/sing v0.7.14 h1:5QQRDCUvYNOMyVp3LuK/hYEBAIv0VsbD3x/l9zH467s= +github.com/sagernet/sing v0.7.14/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= github.com/sagernet/sing-quic v0.5.2-0.20250909083218-00a55617c0fb h1:5Wx3XeTiKrrrcrAky7Hc1bO3CGxrvho2Vu5b/adlEIM= github.com/sagernet/sing-quic v0.5.2-0.20250909083218-00a55617c0fb/go.mod h1:evP1e++ZG8TJHVV5HudXV4vWeYzGfCdF4HwSJZcdqkI= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= @@ -189,31 +375,34 @@ github.com/sagernet/sing-tun v0.7.3 h1:MFnAir+l24ElEyxdfwtY8mqvUUL9nPnL9TDYLkOmV github.com/sagernet/sing-tun v0.7.3/go.mod h1:pUEjh9YHQ2gJT6Lk0TYDklh3WJy7lz+848vleGM3JPM= github.com/sagernet/sing-vmess v0.2.7 h1:2ee+9kO0xW5P4mfe6TYVWf9VtY8k1JhNysBqsiYj0sk= github.com/sagernet/sing-vmess v0.2.7/go.mod h1:5aYoOtYksAyS0NXDm0qKeTYW1yoE1bJVcv+XLcVoyJs= -github.com/sagernet/smux v1.5.34-mod.2 h1:gkmBjIjlJ2zQKpLigOkFur5kBKdV6bNRoFu2WkltRQ4= -github.com/sagernet/smux v1.5.34-mod.2/go.mod h1:0KW0+R+ycvA2INW4gbsd7BNyg+HEfLIAxa5N02/28Zc= +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/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/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/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.80.3-sing-box-1.12-mod.2-extended-1.0.0 h1:Yp4dIRwiwLda9JXyGMHkfYRr2r01NarkzsNd/oi10dk= github.com/shtorm-7/tailscale v1.80.3-sing-box-1.12-mod.2-extended-1.0.0/go.mod h1:+znUAXWwgcgza5mb5do8j9RC95rpY9lbSc/TyEyCGa4= github.com/shtorm-7/wireguard-go v0.0.1-beta.7-extended-1.1.0 h1:bTmx3NiEeH7mdgsifyNUxIEAA0wokRMSm8iS/hln6n0= github.com/shtorm-7/wireguard-go v0.0.1-beta.7-extended-1.1.0/go.mod h1:DHxMTUaBGHP3tf8nJ/N8AkcoJDD0PHECLhTfLsw+ylQ= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.2.3-0.20181224173747-660f15d67dbb/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= +github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= @@ -242,29 +431,37 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 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/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= 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/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/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.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= -go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= -go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= -go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= +go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +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.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.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +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.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -279,70 +476,140 @@ 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-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= -golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 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.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +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-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +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-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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= 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= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +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/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v8 v8.18.2 h1:lFB4DoMU6B626w8ny76MV7VX6W2VHct2GVOI3xgiMrQ= +gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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= gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489 h1:ze1vwAdliUAr68RQ5NtufWaXaOg8WUO2OACzEV+TNdE= gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489/go.mod h1:10sU+Uh5KKNv1+2x2A0Gvzt8FjD3ASIhorV3YsauXhk= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 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= +xorm.io/builder v0.3.7 h1:2pETdKRK+2QG4mLX4oODHEhn5Z8j1m8sXa7jfu+/SZI= +xorm.io/builder v0.3.7/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= +xorm.io/xorm v1.0.2 h1:kZlCh9rqd1AzGwWitcrEEqHE1h1eaZE/ujU5/2tWEtg= +xorm.io/xorm v1.0.2/go.mod h1:o4vnEsQ5V2F1/WK6w4XTwmiWJeGj82tqjAnHe44wVHY= diff --git a/include/registry.go b/include/registry.go index 75386ffc..086e6c3e 100644 --- a/include/registry.go +++ b/include/registry.go @@ -19,10 +19,13 @@ import ( "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/protocol/anytls" "github.com/sagernet/sing-box/protocol/block" + "github.com/sagernet/sing-box/protocol/bond" "github.com/sagernet/sing-box/protocol/direct" protocolDNS "github.com/sagernet/sing-box/protocol/dns" "github.com/sagernet/sing-box/protocol/group" "github.com/sagernet/sing-box/protocol/http" + "github.com/sagernet/sing-box/protocol/limiter/bandwidth" + "github.com/sagernet/sing-box/protocol/limiter/connection" "github.com/sagernet/sing-box/protocol/mieru" "github.com/sagernet/sing-box/protocol/mixed" "github.com/sagernet/sing-box/protocol/naive" @@ -37,6 +40,11 @@ import ( "github.com/sagernet/sing-box/protocol/tunnel" "github.com/sagernet/sing-box/protocol/vless" "github.com/sagernet/sing-box/protocol/vmess" + "github.com/sagernet/sing-box/service/admin_panel" + "github.com/sagernet/sing-box/service/manager" + "github.com/sagernet/sing-box/service/node" + nodeManagerClient "github.com/sagernet/sing-box/service/node_manager/client" + nodeManagerServer "github.com/sagernet/sing-box/service/node_manager/server" "github.com/sagernet/sing-box/service/resolved" "github.com/sagernet/sing-box/service/ssmapi" E "github.com/sagernet/sing/common/exceptions" @@ -66,6 +74,8 @@ func InboundRegistry() *inbound.Registry { vless.RegisterInbound(registry) anytls.RegisterInbound(registry) + bond.RegisterInbound(registry) + registerQUICInbounds(registry) registerStubForRemovedInbounds(registry) @@ -80,6 +90,7 @@ func OutboundRegistry() *outbound.Registry { block.RegisterOutbound(registry) protocolDNS.RegisterOutbound(registry) + group.RegisterFailover(registry) group.RegisterSelector(registry) group.RegisterURLTest(registry) @@ -95,6 +106,11 @@ func OutboundRegistry() *outbound.Registry { mieru.RegisterOutbound(registry) anytls.RegisterOutbound(registry) + bond.RegisterOutbound(registry) + + bandwidth.RegisterOutbound(registry) + connection.RegisterOutbound(registry) + registerQUICOutbounds(registry) registerWireGuardOutbound(registry) registerStubForRemovedOutbounds(registry) @@ -137,6 +153,11 @@ func DNSTransportRegistry() *dns.TransportRegistry { func ServiceRegistry() *service.Registry { registry := service.NewRegistry() + admin_panel.RegisterService(registry) + manager.RegisterService(registry) + node.RegisterService(registry) + nodeManagerClient.RegisterService(registry) + nodeManagerServer.RegisterService(registry) resolved.RegisterService(registry) ssmapi.RegisterService(registry) diff --git a/log/id.go b/log/id.go index 7cac29d2..866170d4 100644 --- a/log/id.go +++ b/log/id.go @@ -13,6 +13,8 @@ func init() { } type idKey struct{} +type muxIdKey struct{} +type hwidKey struct{} type ID struct { ID uint32 @@ -34,3 +36,28 @@ func IDFromContext(ctx context.Context) (ID, bool) { id, loaded := ctx.Value((*idKey)(nil)).(ID) return id, loaded } + +func ContextWithNewMuxID(ctx context.Context) context.Context { + return ContextWithMuxID(ctx, ID{ + ID: rand.Uint32(), + CreatedAt: time.Now(), + }) +} + +func ContextWithMuxID(ctx context.Context, id ID) context.Context { + return context.WithValue(ctx, (*muxIdKey)(nil), id) +} + +func MuxIDFromContext(ctx context.Context) (ID, bool) { + id, loaded := ctx.Value((*muxIdKey)(nil)).(ID) + return id, loaded +} + +func ContextWithHWID(ctx context.Context, id ID) context.Context { + return context.WithValue(ctx, (*hwidKey)(nil), id) +} + +func HWIDFromContext(ctx context.Context) (ID, bool) { + id, loaded := ctx.Value((*hwidKey)(nil)).(ID) + return id, loaded +} diff --git a/option/admin_panel.go b/option/admin_panel.go new file mode 100644 index 00000000..412e3e93 --- /dev/null +++ b/option/admin_panel.go @@ -0,0 +1,13 @@ +package option + +type AdminPanelServiceOptions struct { + ListenOptions + Manager string `json:"manager"` + Database AdminPanelServiceDatabase `json:"database"` + InboundTLSOptionsContainer +} + +type AdminPanelServiceDatabase struct { + Driver string `json:"driver"` + DSN string `json:"dsn"` +} diff --git a/option/bond.go b/option/bond.go new file mode 100644 index 00000000..2e93f44d --- /dev/null +++ b/option/bond.go @@ -0,0 +1,16 @@ +package option + +type BondInboundOptions struct { + Inbounds []Inbound `json:"inbounds"` +} + +type BondOutboundOptions struct { + Outbounds []BondOutbound `json:"outbounds"` +} + +type BondOutbound struct { + Outbound Outbound `json:"outbound"` + DownloadRatio uint8 `json:"download_ratio"` + UploadRatio uint8 `json:"upload_ratio"` + Count uint8 `json:"count"` +} diff --git a/option/group.go b/option/group.go index 02b3a5ec..d550b233 100644 --- a/option/group.go +++ b/option/group.go @@ -16,3 +16,7 @@ type URLTestOutboundOptions struct { IdleTimeout badoption.Duration `json:"idle_timeout,omitempty"` InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` } + +type FailoverOutboundOptions struct { + Outbounds []string `json:"outbounds"` +} diff --git a/option/limiter.go b/option/limiter.go new file mode 100644 index 00000000..0194a10a --- /dev/null +++ b/option/limiter.go @@ -0,0 +1,37 @@ +package option + +import ( + "github.com/sagernet/sing/common/byteformats" +) + +type BandwidthLimiterOutboundOptions struct { + Strategy string `json:"strategy"` + Mode string `json:"mode"` + ConnectionType string `json:"connection_type,omitempty"` + Speed *byteformats.NetworkBytesCompat `json:"speed"` + Users []BandwidthLimiterUser `json:"users,omitempty"` + Route RouteOptions `json:"route"` +} + +type BandwidthLimiterUser struct { + Name string `json:"name"` + Strategy string `json:"strategy"` + Mode string `json:"mode"` + ConnectionType string `json:"connection_type,omitempty"` + Speed *byteformats.NetworkBytesCompat `json:"speed"` +} + +type ConnectionLimiterOutboundOptions struct { + Strategy string `json:"strategy"` + ConnectionType string `json:"connection_type,omitempty"` + Count uint32 `json:"count"` + Users []ConnectionLimiterUser `json:"users,omitempty"` + Route RouteOptions `json:"route"` +} + +type ConnectionLimiterUser struct { + Name string `json:"name"` + Strategy string `json:"strategy"` + ConnectionType string `json:"connection_type,omitempty"` + Count uint32 `json:"count"` +} diff --git a/option/manager.go b/option/manager.go new file mode 100644 index 00000000..f8ee2f6c --- /dev/null +++ b/option/manager.go @@ -0,0 +1,11 @@ +package option + +type ManagerServiceDatabase struct { + Driver string `json:"driver"` + DSN string `json:"dsn"` +} + +type ManagerServiceOptions struct { + Inbounds []string `json:"inbounds"` + Database ManagerServiceDatabase `json:"database"` +} diff --git a/option/node.go b/option/node.go new file mode 100644 index 00000000..da0a33c6 --- /dev/null +++ b/option/node.go @@ -0,0 +1,9 @@ +package option + +type NodeServiceOptions struct { + UUID string + Inbounds []string `json:"inbounds"` + ConnectionLimiters []string `json:"connection_limiters"` + BandwidthLimiters []string `json:"bandwidth_limiters"` + Manager string `json:"manager"` +} diff --git a/option/node_manager.go b/option/node_manager.go new file mode 100644 index 00000000..ce15e3e7 --- /dev/null +++ b/option/node_manager.go @@ -0,0 +1,13 @@ +package option + +type NodeManagerServerServiceOptions struct { + ListenOptions + InboundTLSOptionsContainer + Manager string `json:"manager"` +} + +type NodeManagerClientServiceOptions struct { + DialerOptions + ServerOptions + OutboundTLSOptionsContainer +} diff --git a/option/v2ray_transport.go b/option/v2ray_transport.go index e3698f66..68a7ae3d 100644 --- a/option/v2ray_transport.go +++ b/option/v2ray_transport.go @@ -21,6 +21,7 @@ type _V2RayTransportOptions struct { GRPCOptions V2RayGRPCOptions `json:"-"` HTTPUpgradeOptions V2RayHTTPUpgradeOptions `json:"-"` XHTTPOptions V2RayXHTTPOptions `json:"-"` + KCPOptions V2RayKCPOptions `json:"-"` } type V2RayTransportOptions _V2RayTransportOptions @@ -40,6 +41,8 @@ func (o V2RayTransportOptions) MarshalJSON() ([]byte, error) { v = o.HTTPUpgradeOptions case C.V2RayTransportTypeXHTTP: v = o.XHTTPOptions + case C.V2RayTransportTypeKCP: + v = o.KCPOptions case "": return nil, E.New("missing transport type") default: @@ -67,6 +70,8 @@ func (o *V2RayTransportOptions) UnmarshalJSON(bytes []byte) error { v = &o.HTTPUpgradeOptions case C.V2RayTransportTypeXHTTP: v = &o.XHTTPOptions + case C.V2RayTransportTypeKCP: + v = &o.KCPOptions default: return E.New("unknown transport type: " + o.Type) } @@ -250,3 +255,64 @@ func (m *V2RayXHTTPXmuxOptions) GetNormalizedHMaxRequestTimes() Xbadoption.Range func (m *V2RayXHTTPXmuxOptions) GetNormalizedHMaxReusableSecs() Xbadoption.Range { return m.HMaxReusableSecs } + +type V2RayKCPOptions struct { + MTU uint32 `json:"mtu,omitempty"` + TTI uint32 `json:"tti,omitempty"` + UplinkCapacity uint32 `json:"uplink_capacity,omitempty"` + DownlinkCapacity uint32 `json:"downlink_capacity,omitempty"` + Congestion bool `json:"congestion,omitempty"` + ReadBufferSize uint32 `json:"read_buffer_size,omitempty"` + WriteBufferSize uint32 `json:"write_buffer_size,omitempty"` + HeaderType string `json:"header_type,omitempty"` + Seed string `json:"seed,omitempty"` +} + +func (k *V2RayKCPOptions) GetMTU() uint32 { + if k.MTU == 0 { + return 1350 + } + return k.MTU +} + +func (k *V2RayKCPOptions) GetTTI() uint32 { + if k.TTI == 0 { + return 50 + } + return k.TTI +} + +func (k *V2RayKCPOptions) GetUplinkCapacity() uint32 { + if k.UplinkCapacity == 0 { + return 12 + } + return k.UplinkCapacity +} + +func (k *V2RayKCPOptions) GetDownlinkCapacity() uint32 { + if k.DownlinkCapacity == 0 { + return 100 + } + return k.DownlinkCapacity +} + +func (k *V2RayKCPOptions) GetReadBufferSize() uint32 { + if k.ReadBufferSize == 0 { + return 1 + } + return k.ReadBufferSize +} + +func (k *V2RayKCPOptions) GetWriteBufferSize() uint32 { + if k.WriteBufferSize == 0 { + return 1 + } + return k.WriteBufferSize +} + +func (k *V2RayKCPOptions) GetHeaderType() string { + if k.HeaderType == "" { + return "none" + } + return k.HeaderType +} diff --git a/option/wireguard.go b/option/wireguard.go index 46132841..83ca3871 100644 --- a/option/wireguard.go +++ b/option/wireguard.go @@ -33,15 +33,17 @@ type WireGuardPeer struct { } type WireGuardWARPEndpointOptions struct { - System bool `json:"system,omitempty"` - Name string `json:"name,omitempty"` - ListenPort uint16 `json:"listen_port,omitempty"` - UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` - Workers int `json:"workers,omitempty"` - PreallocatedBuffersPerPool uint32 `json:"preallocated_buffers_per_pool,omitempty"` - DisablePauses bool `json:"disable_pauses,omitempty"` - Amnezia *WireGuardAmnezia `json:"amnezia,omitempty"` - Profile WARPProfile `json:"profile,omitempty"` + System bool `json:"system,omitempty"` + Name string `json:"name,omitempty"` + ListenPort uint16 `json:"listen_port,omitempty"` + UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` + PersistentKeepaliveInterval uint16 `json:"persistent_keepalive_interval,omitempty"` + Reserved []uint8 `json:"reserved,omitempty"` + Workers int `json:"workers,omitempty"` + PreallocatedBuffersPerPool uint32 `json:"preallocated_buffers_per_pool,omitempty"` + DisablePauses bool `json:"disable_pauses,omitempty"` + Amnezia *WireGuardAmnezia `json:"amnezia,omitempty"` + Profile WARPProfile `json:"profile,omitempty"` DialerOptions } diff --git a/protocol/bond/conn.go b/protocol/bond/conn.go new file mode 100644 index 00000000..5ddeeda9 --- /dev/null +++ b/protocol/bond/conn.go @@ -0,0 +1,164 @@ +package bond + +import ( + "encoding/binary" + "errors" + "io" + "net" + "time" +) + +type bondedConn struct { + conns []net.Conn + downloadRatios []uint8 + uploadRatios []uint8 + + readBuffer []byte + readOffset int + readSize int +} + +func NewBondedConn(conns []net.Conn, downloadRatios, uploadRatios []uint8) *bondedConn { + return &bondedConn{ + conns: conns, + downloadRatios: downloadRatios, + uploadRatios: uploadRatios, + readBuffer: make([]byte, 65535), + } +} + +func (c *bondedConn) Read(b []byte) (n int, err error) { + if c.readOffset == c.readSize { + var header [2]byte + _, err := io.ReadFull(c.conns[0], header[:]) + if err != nil { + return 0, err + } + size := int(binary.BigEndian.Uint16(header[:])) + chunkLens := splitByRatios(size, c.downloadRatios) + total := 0 + for i, chunkLen := range chunkLens { + if chunkLen == 0 { + continue + } + chunk := c.readBuffer[total : total+chunkLen] + n, err := io.ReadFull(c.conns[i], chunk) + total += n + if err != nil { + return total, err + } + } + c.readOffset = 0 + c.readSize = size + } + n = copy(b, c.readBuffer[c.readOffset:c.readSize]) + c.readOffset += n + return n, nil +} + +func (c *bondedConn) Write(b []byte) (n int, err error) { + chunkLens := splitByRatios(len(b), c.uploadRatios) + var header [2]byte + binary.BigEndian.PutUint16(header[:], uint16(len(b))) + _, err = c.conns[0].Write(header[:]) + if err != nil { + return 0, err + } + total := 0 + for i, chunkLen := range chunkLens { + if chunkLen == 0 { + continue + } + chunk := b[total : total+chunkLen] + conn := c.conns[i] + subTotal := 0 + for subTotal < len(chunk) { + n, err := conn.Write(chunk[subTotal:]) + subTotal += n + total += n + if err != nil { + return total, err + } + if n == 0 { + return total, io.ErrUnexpectedEOF + } + } + } + return total, err +} + +func (c *bondedConn) Close() error { + errs := make([]error, 0) + for _, conn := range c.conns { + err := conn.Close() + if err != nil { + errs = append(errs, err) + } + } + if len(errs) != 0 { + return errors.Join(errs...) + } + return nil +} + +func (c *bondedConn) LocalAddr() net.Addr { + return nil +} + +func (c *bondedConn) RemoteAddr() net.Addr { + return nil +} + +func (c *bondedConn) SetDeadline(t time.Time) error { + errs := make([]error, 0) + for _, conn := range c.conns { + err := conn.SetDeadline(t) + if err != nil { + errs = append(errs, err) + } + } + if len(errs) != 0 { + return errors.Join(errs...) + } + return nil +} + +func (c *bondedConn) SetReadDeadline(t time.Time) error { + errs := make([]error, 0) + for _, conn := range c.conns { + err := conn.SetReadDeadline(t) + if err != nil { + errs = append(errs, err) + } + } + if len(errs) != 0 { + return errors.Join(errs...) + } + return nil +} + +func (c *bondedConn) SetWriteDeadline(t time.Time) error { + errs := make([]error, 0) + for _, conn := range c.conns { + err := conn.SetWriteDeadline(t) + if err != nil { + errs = append(errs, err) + } + } + if len(errs) != 0 { + return errors.Join(errs...) + } + return nil +} + +func splitByRatios(number int, ratios []uint8) []int { + result := make([]int, len(ratios)) + remaining := number + for i := 0; i < len(ratios)-1; i++ { + part := number * int(ratios[i]) / 100 + result[i] = part + remaining -= part + } + result[len(ratios)-1] = remaining + return result +} diff --git a/protocol/bond/inbound.go b/protocol/bond/inbound.go new file mode 100644 index 00000000..48fe624b --- /dev/null +++ b/protocol/bond/inbound.go @@ -0,0 +1,146 @@ +package bond + +import ( + "context" + "errors" + "net" + "sync" + "time" + + "github.com/patrickmn/go-cache" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/uot" + 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" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.BondInboundOptions](registry, C.TypeBond, NewInbound) +} + +type Inbound struct { + inbound.Adapter + logger logger.ContextLogger + router adapter.ConnectionRouterEx + inbounds []adapter.Inbound + conns *cache.Cache + + mtx sync.Mutex +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.BondInboundOptions) (adapter.Inbound, error) { + if len(options.Inbounds) == 0 { + return nil, E.New("missing tags") + } + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeTunnelServer, tag), + logger: logger, + router: uot.NewRouter(router, logger), + conns: cache.New(C.TCPConnectTimeout, time.Second), + } + inboundRegistry := service.FromContext[adapter.InboundRegistry](ctx) + inbounds := make([]adapter.Inbound, len(options.Inbounds)) + for i, inboundOptions := range options.Inbounds { + inbound, err := inboundRegistry.UnsafeCreate(ctx, NewRouter(router, logger, inbound.connHandler), logger, inboundOptions.Tag, inboundOptions.Type, inboundOptions.Options) + if err != nil { + return nil, err + } + inbounds[i] = inbound + } + inbound.inbounds = inbounds + inbound.conns.OnEvicted(func(s string, i interface{}) { + inbound.mtx.Lock() + defer inbound.mtx.Unlock() + ratioConns := i.(map[uint8]*ratioConn) + for _, ratioConn := range ratioConns { + if ratioConn != nil { + ratioConn.conn.Close() + } + } + }) + return inbound, nil +} + +func (h *Inbound) Start(stage adapter.StartStage) error { + for _, inbound := range h.inbounds { + err := inbound.Start(stage) + if err != nil { + return err + } + } + return nil +} + +func (h *Inbound) Close() error { + errs := make([]error, 0) + for _, inbound := range h.inbounds { + err := inbound.Close() + if err != nil { + errs = append(errs, err) + } + } + if len(errs) != 0 { + return errors.Join(errs...) + } + return nil +} + +func (h *Inbound) connHandler(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) error { + request, err := ReadRequest(conn) + if err != nil { + return err + } + h.mtx.Lock() + defer h.mtx.Unlock() + var ratioConns map[uint8]*ratioConn + rawRatioConns, ok := h.conns.Get(request.UUID.String()) + if ok { + ratioConns = rawRatioConns.(map[uint8]*ratioConn) + } else { + ratioConns = make(map[uint8]*ratioConn, request.Count) + h.conns.SetDefault(request.UUID.String(), ratioConns) + } + ratioConns[request.Index] = &ratioConn{ + conn: conn, + downloadRatio: request.DownloadRatio, + uploadRatio: request.UploadRatio, + } + if len(ratioConns) == int(request.Count) { + conns := make([]net.Conn, len(ratioConns)) + downloadRatios := make([]uint8, len(ratioConns)) + uploadRatios := make([]uint8, len(ratioConns)) + var totalDownloadRatio, totalUploadRatio uint8 + for index, ratioConn := range ratioConns { + conns[index] = ratioConn.conn + downloadRatios[index] = ratioConn.downloadRatio + uploadRatios[index] = ratioConn.uploadRatio + totalDownloadRatio += ratioConn.downloadRatio + totalUploadRatio += ratioConn.uploadRatio + delete(ratioConns, index) + } + if totalDownloadRatio != 100 || totalUploadRatio != 100 { + for _, conn := range conns { + conn.Close() + } + return E.New("invalid ratios") + } + conn = NewBondedConn(conns, downloadRatios, uploadRatios) + metadata.Inbound = h.Tag() + metadata.InboundType = C.TypeBond + metadata.Destination = request.Destination + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) + } + return nil +} + +type ratioConn struct { + conn net.Conn + downloadRatio uint8 + uploadRatio uint8 +} diff --git a/protocol/bond/outbound.go b/protocol/bond/outbound.go new file mode 100644 index 00000000..0f59a746 --- /dev/null +++ b/protocol/bond/outbound.go @@ -0,0 +1,152 @@ +package bond + +import ( + "context" + "errors" + "net" + "sync" + + "github.com/gofrs/uuid/v5" + "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" + "github.com/sagernet/sing/common" + 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/common/uot" + "github.com/sagernet/sing/service" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.BondOutboundOptions](registry, C.TypeBond, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + ctx context.Context + logger logger.ContextLogger + outbounds []adapter.Outbound + downloadRatios []uint8 + uploadRatios []uint8 + uotClient *uot.Client +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.BondOutboundOptions) (adapter.Outbound, error) { + outboundRegistry := service.FromContext[adapter.OutboundRegistry](ctx) + outbounds := make([]adapter.Outbound, 0, len(options.Outbounds)) + downloadRatios := make([]uint8, 0, len(options.Outbounds)) + uploadRatios := make([]uint8, 0, len(options.Outbounds)) + var totalDownloadRatio, totalUploadRatio uint8 + for _, outboundOptions := range options.Outbounds { + count := outboundOptions.Count + if count == 0 { + count = 1 + } + for range count { + outbound, err := outboundRegistry.UnsafeCreateOutbound(ctx, router, logger, outboundOptions.Outbound.Tag, outboundOptions.Outbound.Type, outboundOptions.Outbound.Options) + if err != nil { + return nil, err + } + outbounds = append(outbounds, outbound) + downloadRatios = append(downloadRatios, outboundOptions.DownloadRatio) + uploadRatios = append(uploadRatios, outboundOptions.UploadRatio) + totalDownloadRatio += outboundOptions.DownloadRatio + totalUploadRatio += outboundOptions.UploadRatio + } + } + if totalDownloadRatio != 100 || totalUploadRatio != 100 { + return nil, E.New("invalid ratios") + } + outbound := &Outbound{ + Adapter: outbound.NewAdapter(C.TypeTunnelClient, tag, []string{N.NetworkTCP, N.NetworkUDP}, []string{}), + ctx: ctx, + outbounds: outbounds, + downloadRatios: downloadRatios, + uploadRatios: uploadRatios, + logger: logger, + } + outbound.uotClient = &uot.Client{ + Dialer: outbound, + Version: uot.Version, + } + return outbound, nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if N.NetworkName(network) == N.NetworkUDP { + return h.uotClient.DialContext(ctx, network, destination) + } + conns := make([]net.Conn, len(h.outbounds)) + connUUID, err := uuid.NewV4() + if err != nil { + return nil, err + } + errs := make([]error, 0, len(conns)) + var mtx sync.Mutex + var wg sync.WaitGroup + for i, outbound := range h.outbounds { + wg.Go( + func() { + conn, err := outbound.DialContext(ctx, network, Destination) + if err != nil { + mtx.Lock() + errs = append(errs, err) + mtx.Unlock() + return + } + err = WriteRequest( + conn, + &Request{ + UUID: connUUID, + Index: byte(i), + Count: byte(len(h.outbounds)), + DownloadRatio: h.uploadRatios[i], + UploadRatio: h.downloadRatios[i], + Destination: destination, + }, + ) + if err != nil { + conn.Close() + mtx.Lock() + errs = append(errs, err) + mtx.Unlock() + return + } + conns[i] = conn + }, + ) + } + wg.Wait() + if len(errs) != 0 { + for _, conn := range conns { + if conn != nil { + conn.Close() + } + } + return nil, errors.Join(errs...) + } + conn := NewBondedConn(conns, h.downloadRatios, h.uploadRatios) + return conn, nil +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return h.uotClient.ListenPacket(ctx, destination) +} + +func (h *Outbound) Close() error { + errs := make([]error, 0) + for _, outbound := range h.outbounds { + err := common.Close(outbound) + if err != nil { + errs = append(errs, err) + } + } + if len(errs) != 0 { + return errors.Join(errs...) + } + return nil +} diff --git a/protocol/bond/protocol.go b/protocol/bond/protocol.go new file mode 100644 index 00000000..5875d0ed --- /dev/null +++ b/protocol/bond/protocol.go @@ -0,0 +1,100 @@ +package bond + +import ( + "encoding/binary" + "io" + + "github.com/gofrs/uuid/v5" + "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" +) + +const ( + Version = 0 +) + +var Destination = M.Socksaddr{ + Fqdn: "sp.bond.sing-box.arpa", + Port: 444, +} + +var AddressSerializer = M.NewSerializer( + M.AddressFamilyByte(0x01, M.AddressFamilyIPv4), + M.AddressFamilyByte(0x03, M.AddressFamilyIPv6), + M.AddressFamilyByte(0x02, M.AddressFamilyFqdn), + M.PortThenAddress(), +) + +type Request struct { + UUID uuid.UUID + Index byte + Count byte + DownloadRatio byte + UploadRatio byte + Destination M.Socksaddr +} + +func ReadRequest(reader io.Reader) (*Request, error) { + var request Request + var version uint8 + err := binary.Read(reader, binary.BigEndian, &version) + if err != nil { + return nil, err + } + if version != Version { + return nil, E.New("unknown version: ", version) + } + _, err = io.ReadFull(reader, request.UUID[:]) + if err != nil { + return nil, err + } + err = binary.Read(reader, binary.BigEndian, &request.Index) + if err != nil { + return nil, err + } + err = binary.Read(reader, binary.BigEndian, &request.Count) + if err != nil { + return nil, err + } + err = binary.Read(reader, binary.BigEndian, &request.DownloadRatio) + if err != nil { + return nil, err + } + err = binary.Read(reader, binary.BigEndian, &request.UploadRatio) + if err != nil { + return nil, err + } + request.Destination, err = AddressSerializer.ReadAddrPort(reader) + if err != nil { + return nil, err + } + return &request, nil +} + +func WriteRequest(writer io.Writer, request *Request) error { + var requestLen int + requestLen += 1 // version + requestLen += 16 // UUID + requestLen += 1 // index + requestLen += 1 // count + requestLen += 1 // download ratio + requestLen += 1 // upload ratio + requestLen += AddressSerializer.AddrPortLen(request.Destination) + buffer := buf.NewSize(requestLen) + defer buffer.Release() + common.Must( + buffer.WriteByte(Version), + common.Error(buffer.Write(request.UUID[:])), + buffer.WriteByte(request.Index), + buffer.WriteByte(request.Count), + buffer.WriteByte(request.DownloadRatio), + buffer.WriteByte(request.UploadRatio), + ) + err := AddressSerializer.WriteAddrPort(buffer, request.Destination) + if err != nil { + return err + } + return common.Error(writer.Write(buffer.Bytes())) +} diff --git a/protocol/bond/router.go b/protocol/bond/router.go new file mode 100644 index 00000000..04ea5a7d --- /dev/null +++ b/protocol/bond/router.go @@ -0,0 +1,41 @@ +package bond + +import ( + "context" + "net" + "os" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" +) + +type Router struct { + adapter.Router + logger logger.ContextLogger + handler func(context.Context, net.Conn, adapter.InboundContext, N.CloseHandlerFunc) error +} + +func NewRouter(router adapter.Router, logger logger.ContextLogger, handler func(context.Context, net.Conn, adapter.InboundContext, N.CloseHandlerFunc) error) *Router { + return &Router{Router: router, logger: logger, handler: handler} +} + +func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { + return r.handler(ctx, conn, metadata, func(error) {}) +} + +func (r *Router) RoutePacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error { + return os.ErrInvalid +} + +func (r *Router) RouteConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + if err := r.handler(ctx, conn, metadata, onClose); err != nil { + r.logger.ErrorContext(ctx, err) + N.CloseOnHandshakeFailure(conn, onClose, err) + } +} + +func (r *Router) RoutePacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + r.logger.ErrorContext(ctx, os.ErrInvalid) + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) +} diff --git a/protocol/group/failover.go b/protocol/group/failover.go new file mode 100644 index 00000000..f20527be --- /dev/null +++ b/protocol/group/failover.go @@ -0,0 +1,96 @@ +package group + +import ( + "context" + "net" + + "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" + "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 RegisterFailover(registry *outbound.Registry) { + outbound.Register[option.FailoverOutboundOptions](registry, C.TypeFailover, NewFailover) +} + +var ( + _ adapter.OutboundGroup = (*Failover)(nil) +) + +type Failover struct { + outbound.Adapter + ctx context.Context + outbound adapter.OutboundManager + logger logger.ContextLogger + tags []string + outbounds map[string]adapter.Outbound +} + +func NewFailover(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.FailoverOutboundOptions) (adapter.Outbound, error) { + if len(options.Outbounds) == 0 { + return nil, E.New("missing tags") + } + outbound := &Failover{ + Adapter: outbound.NewAdapter(C.TypeFailover, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.Outbounds), + ctx: ctx, + outbound: service.FromContext[adapter.OutboundManager](ctx), + logger: logger, + tags: options.Outbounds, + outbounds: make(map[string]adapter.Outbound, len(options.Outbounds)), + } + return outbound, nil +} + +func (s *Failover) Start() error { + for i, tag := range s.tags { + outbound, loaded := s.outbound.Outbound(tag) + if !loaded { + return E.New("outbound ", i, " not found: ", tag) + } + s.outbounds[tag] = outbound + } + return nil +} + +func (s *Failover) Now() string { + return s.tags[0] +} + +func (s *Failover) All() []string { + return s.tags +} + +func (s *Failover) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + var conn net.Conn + var err error + for _, outbound := range s.outbounds { + conn, err = outbound.DialContext(ctx, network, destination) + if err != nil { + s.logger.ErrorContext(ctx, err) + continue + } + return conn, nil + } + return nil, err +} + +func (s *Failover) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + var conn net.PacketConn + var err error + for _, outbound := range s.outbounds { + conn, err = outbound.ListenPacket(ctx, destination) + if err != nil { + s.logger.ErrorContext(ctx, err) + continue + } + return conn, nil + } + return nil, err +} diff --git a/protocol/hysteria/inbound.go b/protocol/hysteria/inbound.go index 5afc440d..25acf062 100644 --- a/protocol/hysteria/inbound.go +++ b/protocol/hysteria/inbound.go @@ -180,3 +180,11 @@ func (h *Inbound) Close() error { common.PtrOrNil(h.service), ) } + +func (h *Inbound) UpdateUsers(users []option.HysteriaUser) { + h.service.UpdateUsers(common.MapIndexed(users, func(index int, _ option.HysteriaUser) int { + return index + }), common.Map(users, func(it option.HysteriaUser) string { + return it.AuthString + })) +} diff --git a/protocol/hysteria2/inbound.go b/protocol/hysteria2/inbound.go index f55b6ae8..da71dac8 100644 --- a/protocol/hysteria2/inbound.go +++ b/protocol/hysteria2/inbound.go @@ -213,3 +213,11 @@ func (h *Inbound) Close() error { common.PtrOrNil(h.service), ) } + +func (h *Inbound) UpdateUsers(users []option.Hysteria2User) { + h.service.UpdateUsers(common.MapIndexed(users, func(index int, _ option.Hysteria2User) int { + return index + }), common.Map(users, func(it option.Hysteria2User) string { + return it.Password + })) +} diff --git a/protocol/limiter/bandwidth/limiter.go b/protocol/limiter/bandwidth/limiter.go new file mode 100644 index 00000000..404b9c15 --- /dev/null +++ b/protocol/limiter/bandwidth/limiter.go @@ -0,0 +1,158 @@ +package bandwidth + +import ( + "context" + "net" + + "golang.org/x/time/rate" +) + +type connWithDownloadBandwidthLimiter struct { + net.Conn + ctx context.Context + limiter *rate.Limiter + burst int +} + +func NewConnWithDownloadBandwidthLimiter(ctx context.Context, conn net.Conn, limiter *rate.Limiter) *connWithDownloadBandwidthLimiter { + return &connWithDownloadBandwidthLimiter{conn, ctx, limiter, limiter.Burst()} +} + +func (conn *connWithDownloadBandwidthLimiter) Write(p []byte) (n int, err error) { + var nn int + for { + end := len(p) + if end == 0 { + break + } + if conn.burst < len(p) { + end = conn.burst + } + err = conn.limiter.WaitN(conn.ctx, end) + if err != nil { + return + } + nn, err = conn.Conn.Write(p[:end]) + n += nn + if err != nil { + return + } + p = p[end:] + } + return +} + +type connWithUploadBandwidthLimiter struct { + net.Conn + ctx context.Context + limiter *rate.Limiter + burst int +} + +func NewConnWithUploadBandwidthLimiter(ctx context.Context, conn net.Conn, limiter *rate.Limiter) *connWithUploadBandwidthLimiter { + return &connWithUploadBandwidthLimiter{conn, ctx, limiter, limiter.Burst()} +} + +func (conn *connWithUploadBandwidthLimiter) Read(p []byte) (n int, err error) { + if conn.burst < len(p) { + p = p[:conn.burst] + } + n, err = conn.Conn.Read(p) + if err != nil { + return + } + err = conn.limiter.WaitN(conn.ctx, n) + if err != nil { + return + } + return +} + +type connWithCloseHandler struct { + net.Conn + onClose CloseHandlerFunc +} + +func NewConnWithCloseHandler(conn net.Conn, onClose CloseHandlerFunc) *connWithCloseHandler { + return &connWithCloseHandler{conn, onClose} +} + +func (conn *connWithCloseHandler) Close() error { + conn.onClose() + return conn.Conn.Close() +} + +type packetConnWithDownloadBandwidthLimiter struct { + net.PacketConn + ctx context.Context + limiter *rate.Limiter + burst int +} + +func NewPacketConnWithDownloadBandwidthLimiter(ctx context.Context, conn net.PacketConn, limiter *rate.Limiter) *packetConnWithDownloadBandwidthLimiter { + return &packetConnWithDownloadBandwidthLimiter{conn, ctx, limiter, limiter.Burst()} +} + +func (conn *packetConnWithDownloadBandwidthLimiter) WriteTo(p []byte, addr net.Addr) (n int, err error) { + var nn int + for { + end := len(p) + if end == 0 { + break + } + if conn.burst < len(p) { + end = conn.burst + } + err = conn.limiter.WaitN(conn.ctx, end) + if err != nil { + return + } + nn, err = conn.PacketConn.WriteTo(p[:end], addr) + n += nn + if err != nil { + return + } + p = p[end:] + } + return +} + +type packetConnWithUploadBandwidthLimiter struct { + net.PacketConn + ctx context.Context + limiter *rate.Limiter + burst int +} + +func NewPacketConnWithUploadBandwidthLimiter(ctx context.Context, conn net.PacketConn, limiter *rate.Limiter) *packetConnWithUploadBandwidthLimiter { + return &packetConnWithUploadBandwidthLimiter{conn, ctx, limiter, limiter.Burst()} +} + +func (conn *packetConnWithUploadBandwidthLimiter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { + if conn.burst < len(p) { + p = p[:conn.burst] + } + n, addr, err = conn.PacketConn.ReadFrom(p) + if err != nil { + return + } + err = conn.limiter.WaitN(conn.ctx, n) + if err != nil { + return + } + return +} + +type packetConnWithCloseHandler struct { + net.PacketConn + onClose CloseHandlerFunc +} + +func NewPacketConnWithCloseHandler(conn net.PacketConn, onClose CloseHandlerFunc) *packetConnWithCloseHandler { + return &packetConnWithCloseHandler{conn, onClose} +} + +func (conn *packetConnWithCloseHandler) Close() error { + conn.onClose() + return conn.PacketConn.Close() +} diff --git a/protocol/limiter/bandwidth/outbound.go b/protocol/limiter/bandwidth/outbound.go new file mode 100644 index 00000000..92782278 --- /dev/null +++ b/protocol/limiter/bandwidth/outbound.go @@ -0,0 +1,146 @@ +package bandwidth + +import ( + "context" + "net" + + "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" + "github.com/sagernet/sing-box/route" + + "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.BandwidthLimiterOutboundOptions](registry, C.TypeBandwidthLimiter, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + ctx context.Context + outbound adapter.OutboundManager + connection adapter.ConnectionManager + logger logger.ContextLogger + strategy BandwidthStrategy + outboundTag string + detour adapter.Outbound + router *route.Router +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.BandwidthLimiterOutboundOptions) (adapter.Outbound, error) { + if options.Strategy == "" { + return nil, E.New("missing strategy") + } + if options.Route.Final == "" { + return nil, E.New("missing final outbound") + } + var strategy BandwidthStrategy + var err error + switch options.Strategy { + case "users": + usersStrategies := make(map[string]BandwidthStrategy, len(options.Users)) + for _, user := range options.Users { + userStrategy, err := CreateStrategy(user.Strategy, user.Mode, user.ConnectionType, options.Speed.Value()) + if err != nil { + return nil, err + } + usersStrategies[user.Name] = userStrategy + } + strategy = NewUsersBandwidthStrategy(usersStrategies) + case "manager": + strategy = NewManagerBandwidthStrategy() + default: + strategy, err = CreateStrategy(options.Strategy, options.Mode, options.ConnectionType, options.Speed.Value()) + if err != nil { + return nil, err + } + } + logFactory := service.FromContext[log.Factory](ctx) + r := route.NewRouter(ctx, logFactory, options.Route, option.DNSOptions{}) + err = r.Initialize(options.Route.Rules, options.Route.RuleSet) + if err != nil { + return nil, err + } + outbound := &Outbound{ + Adapter: outbound.NewAdapter(C.TypeBandwidthLimiter, tag, nil, []string{}), + ctx: ctx, + outbound: service.FromContext[adapter.OutboundManager](ctx), + connection: service.FromContext[adapter.ConnectionManager](ctx), + logger: logger, + strategy: strategy, + outboundTag: options.Route.Final, + router: r, + } + return outbound, nil +} + +func (h *Outbound) Network() []string { + return []string{N.NetworkTCP, N.NetworkUDP} +} + +func (h *Outbound) Start() error { + detour, loaded := h.outbound.Outbound(h.outboundTag) + if !loaded { + return E.New("outbound not found: ", h.outboundTag) + } + h.detour = detour + for _, stage := range []adapter.StartStage{adapter.StartStateStart, adapter.StartStatePostStart, adapter.StartStateStarted} { + err := h.router.Start(stage) + if err != nil { + return err + } + } + return nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + conn, err := h.detour.DialContext(ctx, network, destination) + if err != nil { + return nil, err + } + return h.strategy.wrapConn(ctx, conn, adapter.ContextFrom(ctx), true) +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + conn, err := h.detour.ListenPacket(ctx, destination) + if err != nil { + return nil, err + } + return h.strategy.wrapPacketConn(ctx, conn, adapter.ContextFrom(ctx), true) +} + +func (h *Outbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + conn, err := h.strategy.wrapConn(ctx, conn, &metadata, false) + if err != nil { + h.logger.ErrorContext(ctx, err) + return + } + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) + return +} + +func (h *Outbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + packetConn, err := h.strategy.wrapPacketConn(ctx, bufio.NewNetPacketConn(conn), &metadata, false) + if err != nil { + h.logger.ErrorContext(ctx, err) + return + } + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + h.router.RoutePacketConnectionEx(ctx, bufio.NewPacketConn(packetConn), metadata, onClose) + return +} + +func (h *Outbound) GetStrategy() BandwidthStrategy { + return h.strategy +} diff --git a/protocol/limiter/bandwidth/strategy.go b/protocol/limiter/bandwidth/strategy.go new file mode 100644 index 00000000..bc46ee21 --- /dev/null +++ b/protocol/limiter/bandwidth/strategy.go @@ -0,0 +1,266 @@ +package bandwidth + +import ( + "context" + "net" + "strconv" + "sync" + + "github.com/sagernet/sing-box/adapter" + "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 *rate.Limiter, reverse bool) net.Conn + PacketConnWrapper = func(ctx context.Context, conn net.PacketConn, limiter *rate.Limiter, reverse bool) net.PacketConn +) + +type BandwidthStrategy interface { + wrapConn(ctx context.Context, conn net.Conn, metadata *adapter.InboundContext, reverse bool) (net.Conn, error) + wrapPacketConn(ctx context.Context, conn net.PacketConn, metadata *adapter.InboundContext, reverse bool) (net.PacketConn, error) +} + +type BandwidthLimiterStrategy interface { + getLimiter(ctx context.Context, metadata *adapter.InboundContext) (*rate.Limiter, CloseHandlerFunc, error) +} + +type DefaultWrapStrategy struct { + limiterStrategy BandwidthLimiterStrategy + connWrapper ConnWrapper + packetConnWrapper PacketConnWrapper +} + +func NewDefaultWrapStrategy(limiterStrategy BandwidthLimiterStrategy, connWrapper ConnWrapper, packetConnWrapper PacketConnWrapper) *DefaultWrapStrategy { + return &DefaultWrapStrategy{limiterStrategy, connWrapper, packetConnWrapper} +} + +func (s *DefaultWrapStrategy) wrapConn(ctx context.Context, conn net.Conn, metadata *adapter.InboundContext, reverse bool) (net.Conn, error) { + limiter, onClose, err := s.limiterStrategy.getLimiter(ctx, metadata) + if err != nil { + return nil, err + } + return NewConnWithCloseHandler(s.connWrapper(ctx, conn, limiter, reverse), onClose), nil +} + +func (s *DefaultWrapStrategy) wrapPacketConn(ctx context.Context, conn net.PacketConn, metadata *adapter.InboundContext, reverse bool) (net.PacketConn, error) { + limiter, onClose, err := s.limiterStrategy.getLimiter(ctx, metadata) + if err != nil { + return nil, err + } + return NewPacketConnWithCloseHandler(s.packetConnWrapper(ctx, conn, limiter, reverse), onClose), nil +} + +type GlobalBandwidthStrategy struct { + limiter *rate.Limiter +} + +func NewGlobalBandwidthStrategy(speed uint64) *GlobalBandwidthStrategy { + return &GlobalBandwidthStrategy{ + limiter: createSpeedLimiter(speed), + } +} + +func (s *GlobalBandwidthStrategy) getLimiter(ctx context.Context, metadata *adapter.InboundContext) (*rate.Limiter, CloseHandlerFunc, error) { + return s.limiter, func() {}, nil +} + +type idBandwidthLimiter struct { + limiter *rate.Limiter + handles uint32 +} + +type ConnectionBandwidthStrategy struct { + limiters map[string]*idBandwidthLimiter + connIDGetter ConnIDGetter + speed uint64 + mtx sync.Mutex +} + +func NewConnectionBandwidthStrategy(connIDGetter ConnIDGetter, speed uint64) *ConnectionBandwidthStrategy { + return &ConnectionBandwidthStrategy{ + limiters: make(map[string]*idBandwidthLimiter), + connIDGetter: connIDGetter, + speed: speed, + } +} + +func (s *ConnectionBandwidthStrategy) getLimiter(ctx context.Context, metadata *adapter.InboundContext) (*rate.Limiter, CloseHandlerFunc, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + id, ok := s.connIDGetter(ctx, metadata) + if !ok { + return nil, nil, E.New("id not found") + } + limiter, ok := s.limiters[id] + if !ok { + limiter = &idBandwidthLimiter{ + limiter: createSpeedLimiter(s.speed), + } + s.limiters[id] = limiter + } + limiter.handles++ + var once sync.Once + return limiter.limiter, func() { + once.Do(func() { + s.mtx.Lock() + defer s.mtx.Unlock() + limiter.handles-- + if limiter.handles == 0 { + delete(s.limiters, id) + } + }) + }, nil +} + +type UsersBandwidthStrategy struct { + strategies map[string]BandwidthStrategy + mtx sync.Mutex +} + +func NewUsersBandwidthStrategy(strategies map[string]BandwidthStrategy) *UsersBandwidthStrategy { + return &UsersBandwidthStrategy{ + strategies: strategies, + } +} + +func (s *UsersBandwidthStrategy) wrapConn(ctx context.Context, conn net.Conn, metadata *adapter.InboundContext, reverse bool) (net.Conn, error) { + strategy, err := s.getStrategy(ctx, metadata) + if err != nil { + return nil, err + } + return strategy.wrapConn(ctx, conn, metadata, reverse) +} + +func (s *UsersBandwidthStrategy) wrapPacketConn(ctx context.Context, conn net.PacketConn, metadata *adapter.InboundContext, reverse bool) (net.PacketConn, error) { + strategy, err := s.getStrategy(ctx, metadata) + if err != nil { + return nil, err + } + return strategy.wrapPacketConn(ctx, conn, metadata, reverse) +} + +func (s *UsersBandwidthStrategy) getStrategy(ctx context.Context, metadata *adapter.InboundContext) (BandwidthStrategy, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + var user string + if metadata != nil { + user = metadata.User + } + strategy, ok := s.strategies[user] + if ok { + return strategy, nil + } + return nil, E.New("user strategy not found: ", user) +} + +type ManagerBandwidthStrategy struct { + *UsersBandwidthStrategy +} + +func NewManagerBandwidthStrategy() *ManagerBandwidthStrategy { + return &ManagerBandwidthStrategy{ + UsersBandwidthStrategy: NewUsersBandwidthStrategy(map[string]BandwidthStrategy{}), + } +} + +func (s *ManagerBandwidthStrategy) UpdateStrategies(strategies map[string]BandwidthStrategy) { + s.mtx.Lock() + defer s.mtx.Unlock() + s.strategies = strategies +} + +func CreateStrategy(strategy string, mode string, connectionType string, speed uint64) (BandwidthStrategy, error) { + var limiterStrategy BandwidthLimiterStrategy + switch strategy { + case "global": + limiterStrategy = NewGlobalBandwidthStrategy(speed) + case "connection": + var connIDGetter ConnIDGetter + switch connectionType { + case "mux": + connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + id, ok := log.MuxIDFromContext(ctx) + if !ok { + return "", ok + } + return strconv.FormatUint(uint64(id.ID), 10), ok + } + case "hwid": + connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + id, ok := ctx.Value("hwid").(string) + return id, ok + } + case "ip": + connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + return metadata.Source.IPAddr().String(), true + } + default: + return nil, E.New("connection type not found: ", connectionType) + } + limiterStrategy = NewConnectionBandwidthStrategy(connIDGetter, speed) + default: + return nil, E.New("strategy not found: ", strategy) + } + var ( + connWrapper ConnWrapper + packetConnWrapper PacketConnWrapper + ) + switch mode { + case "download": + connWrapper = connWithDownloadBandwidthWrapper + packetConnWrapper = packetConnWithDownloadBandwidthWrapper + case "upload": + connWrapper = connWithUploadBandwidthWrapper + packetConnWrapper = packetConnWithUploadBandwidthWrapper + case "duplex": + connWrapper = connWithDuplexBandwidthWrapper + packetConnWrapper = packetConnWithDuplexBandwidthWrapper + default: + return nil, E.New("mode not found: ", mode) + } + return NewDefaultWrapStrategy(limiterStrategy, connWrapper, packetConnWrapper), nil +} + +func createSpeedLimiter(speed uint64) *rate.Limiter { + return rate.NewLimiter(rate.Limit(float64(speed)), 10000) +} + +func connWithDownloadBandwidthWrapper(ctx context.Context, conn net.Conn, limiter *rate.Limiter, 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 *rate.Limiter, reverse bool) net.Conn { + if reverse { + return NewConnWithDownloadBandwidthLimiter(ctx, conn, limiter) + } + return NewConnWithUploadBandwidthLimiter(ctx, conn, limiter) +} + +func connWithDuplexBandwidthWrapper(ctx context.Context, conn net.Conn, limiter *rate.Limiter, reverse bool) net.Conn { + return NewConnWithUploadBandwidthLimiter(ctx, NewConnWithDownloadBandwidthLimiter(ctx, conn, limiter), limiter) +} + +func packetConnWithDownloadBandwidthWrapper(ctx context.Context, conn net.PacketConn, limiter *rate.Limiter, 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 *rate.Limiter, reverse bool) net.PacketConn { + if reverse { + return NewPacketConnWithDownloadBandwidthLimiter(ctx, conn, limiter) + } + return NewPacketConnWithUploadBandwidthLimiter(ctx, conn, limiter) +} + +func packetConnWithDuplexBandwidthWrapper(ctx context.Context, conn net.PacketConn, limiter *rate.Limiter, reverse bool) net.PacketConn { + return NewPacketConnWithUploadBandwidthLimiter(ctx, NewPacketConnWithDownloadBandwidthLimiter(ctx, conn, limiter), limiter) +} diff --git a/protocol/limiter/connection/lock.go b/protocol/limiter/connection/lock.go new file mode 100644 index 00000000..c646c0b4 --- /dev/null +++ b/protocol/limiter/connection/lock.go @@ -0,0 +1,37 @@ +package connection + +import ( + "context" + "sync" + + 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) { + mtx.Lock() + defer mtx.Unlock() + handles, ok := locks[id] + if !ok { + if len(locks) == int(max) { + return nil, nil, E.New("not enough free locks") + } + handles = new(uint32) + 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) + } + }) + }, nil, nil + } +} diff --git a/protocol/limiter/connection/outbound.go b/protocol/limiter/connection/outbound.go new file mode 100644 index 00000000..e37ee23d --- /dev/null +++ b/protocol/limiter/connection/outbound.go @@ -0,0 +1,204 @@ +package connection + +import ( + "context" + "net" + + "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" + "github.com/sagernet/sing-box/route" + "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.ConnectionLimiterOutboundOptions](registry, C.TypeConnectionLimiter, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + ctx context.Context + outbound adapter.OutboundManager + connection adapter.ConnectionManager + logger logger.ContextLogger + strategy ConnectionStrategy + outboundTag string + detour adapter.Outbound + router *route.Router +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ConnectionLimiterOutboundOptions) (adapter.Outbound, error) { + if options.Strategy == "" { + return nil, E.New("missing strategy") + } + if options.Route.Final == "" { + return nil, E.New("missing final outbound") + } + var strategy ConnectionStrategy + var err error + switch options.Strategy { + case "users": + usersStrategies := make(map[string]ConnectionStrategy, len(options.Users)) + for _, user := range options.Users { + userStrategy, err := CreateStrategy(user.Strategy, user.ConnectionType, NewDefaultLock(user.Count)) + if err != nil { + return nil, err + } + usersStrategies[user.Name] = userStrategy + } + strategy = NewUsersConnectionStrategy(usersStrategies) + case "manager": + strategy = NewManagerConnectionStrategy() + default: + strategy, err = CreateStrategy(options.Strategy, options.ConnectionType, NewDefaultLock(options.Count)) + if err != nil { + return nil, err + } + } + logFactory := service.FromContext[log.Factory](ctx) + r := route.NewRouter(ctx, logFactory, options.Route, option.DNSOptions{}) + err = r.Initialize(options.Route.Rules, options.Route.RuleSet) + if err != nil { + return nil, err + } + outbound := &Outbound{ + Adapter: outbound.NewAdapter(C.TypeConnectionLimiter, tag, nil, []string{}), + ctx: ctx, + outbound: service.FromContext[adapter.OutboundManager](ctx), + connection: service.FromContext[adapter.ConnectionManager](ctx), + logger: logger, + outboundTag: options.Route.Final, + strategy: strategy, + router: r, + } + return outbound, nil +} + +func (h *Outbound) Network() []string { + return []string{N.NetworkTCP, N.NetworkUDP} +} + +func (h *Outbound) Start() error { + detour, loaded := h.outbound.Outbound(h.outboundTag) + if !loaded { + return E.New("outbound not found: ", h.outboundTag) + } + h.detour = detour + for _, stage := range []adapter.StartStage{adapter.StartStateStart, adapter.StartStatePostStart, adapter.StartStateStarted} { + err := h.router.Start(stage) + if err != nil { + return err + } + } + return nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + onClose, lockCtx, err := h.strategy.request(ctx, adapter.ContextFrom(ctx)) + if err != nil { + return nil, err + } + conn, err := h.detour.DialContext(ctx, network, destination) + if err != nil { + onClose() + return nil, err + } + conn = newConnWithCloseHandlerFunc(conn, onClose) + if lockCtx != nil { + go connChecker(lockCtx, conn.Close) + } + return conn, nil +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + onClose, lockCtx, err := h.strategy.request(ctx, adapter.ContextFrom(ctx)) + if err != nil { + return nil, err + } + conn, err := h.detour.ListenPacket(ctx, destination) + if err != nil { + onClose() + return nil, err + } + conn = newPacketConnWithCloseHandlerFunc(conn, onClose) + if lockCtx != nil { + go connChecker(lockCtx, conn.Close) + } + return conn, nil +} + +func (h *Outbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + limiterOnClose, lockCtx, err := h.strategy.request(ctx, &metadata) + if err != nil { + h.logger.ErrorContext(ctx, err) + return + } + conn = newConnWithCloseHandlerFunc(conn, limiterOnClose) + if lockCtx != nil { + go connChecker(lockCtx, conn.Close) + } + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) + return +} + +func (h *Outbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + limiterOnClose, lockCtx, err := h.strategy.request(ctx, &metadata) + if err != nil { + h.logger.ErrorContext(ctx, err) + return + } + conn = bufio.NewPacketConn(newPacketConnWithCloseHandlerFunc(bufio.NewNetPacketConn(conn), limiterOnClose)) + if lockCtx != nil { + go connChecker(lockCtx, conn.Close) + } + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) + return +} + +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() + closeFunc() +} diff --git a/protocol/limiter/connection/strategy.go b/protocol/limiter/connection/strategy.go new file mode 100644 index 00000000..b90db995 --- /dev/null +++ b/protocol/limiter/connection/strategy.go @@ -0,0 +1,119 @@ +package connection + +import ( + "context" + "strconv" + "sync" + + "github.com/sagernet/sing-box/adapter" + "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) + + ConnectionStrategy interface { + request(ctx context.Context, metadata *adapter.InboundContext) (onClose CloseHandlerFunc, lockCtx context.Context, err error) + } +) + +type DefaultConnectionStrategy struct { + connIDGetter ConnIDGetter + lockIDGetter LockIDGetter + + mtx sync.Mutex +} + +func NewDefaultConnectionStrategy(connIDGetter ConnIDGetter, lockIDGetter LockIDGetter) *DefaultConnectionStrategy { + outbound := &DefaultConnectionStrategy{ + connIDGetter: connIDGetter, + lockIDGetter: lockIDGetter, + } + return outbound +} + +func (s *DefaultConnectionStrategy) request(ctx context.Context, metadata *adapter.InboundContext) (CloseHandlerFunc, context.Context, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + id, ok := s.connIDGetter(ctx, metadata) + if !ok { + return nil, nil, E.New("id not found") + } + return s.lockIDGetter(id) +} + +type UsersConnectionStrategy struct { + strategies map[string]ConnectionStrategy + mtx sync.Mutex +} + +func NewUsersConnectionStrategy(strategies map[string]ConnectionStrategy) *UsersConnectionStrategy { + return &UsersConnectionStrategy{ + strategies: strategies, + } +} + +func (s *UsersConnectionStrategy) request(ctx context.Context, metadata *adapter.InboundContext) (CloseHandlerFunc, context.Context, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + var user string + if metadata != nil { + user = metadata.User + } + strategy, ok := s.strategies[user] + if ok { + return strategy.request(ctx, metadata) + } + return nil, nil, E.New("user strategy not found: ", user) +} + +type ManagerConnectionStrategy struct { + *UsersConnectionStrategy +} + +func NewManagerConnectionStrategy() *ManagerConnectionStrategy { + return &ManagerConnectionStrategy{ + UsersConnectionStrategy: NewUsersConnectionStrategy(map[string]ConnectionStrategy{}), + } +} + +func (s *ManagerConnectionStrategy) UpdateStrategies(strategies map[string]ConnectionStrategy) { + s.mtx.Lock() + defer s.mtx.Unlock() + s.strategies = strategies +} + +func CreateStrategy(strategy string, connectionType string, lockIDGetter LockIDGetter) (ConnectionStrategy, error) { + switch strategy { + case "connection": + var connIDGetter ConnIDGetter + switch connectionType { + case "mux": + connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + id, ok := log.MuxIDFromContext(ctx) + if !ok { + return "", ok + } + return strconv.FormatUint(uint64(id.ID), 10), ok + } + case "hwid": + connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + id, ok := ctx.Value("hwid").(string) + return id, ok + } + case "ip": + connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + return metadata.Source.IPAddr().String(), true + } + default: + return nil, E.New("connection type not found: ", connectionType) + } + return NewDefaultConnectionStrategy(connIDGetter, lockIDGetter), nil + default: + return nil, E.New("strategy not found: ", strategy) + } +} diff --git a/protocol/trojan/inbound.go b/protocol/trojan/inbound.go index ec95a81e..526924ab 100644 --- a/protocol/trojan/inbound.go +++ b/protocol/trojan/inbound.go @@ -158,6 +158,14 @@ func (h *Inbound) Close() error { ) } +func (h *Inbound) UpdateUsers(users []option.TrojanUser) { + h.service.UpdateUsers(common.MapIndexed(users, func(index int, _ option.TrojanUser) int { + return index + }), common.Map(users, func(it option.TrojanUser) string { + return it.Password + })) +} + func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { if h.tlsConfig != nil && h.transport == nil { tlsConn, err := tls.ServerHandshake(ctx, conn, h.tlsConfig) diff --git a/protocol/tuic/inbound.go b/protocol/tuic/inbound.go index c4c63236..1bfb0ec2 100644 --- a/protocol/tuic/inbound.go +++ b/protocol/tuic/inbound.go @@ -170,3 +170,13 @@ func (h *Inbound) Close() error { common.PtrOrNil(h.server), ) } + +func (h *Inbound) UpdateUsers(users []option.TUICUser) { + h.server.UpdateUsers(common.MapIndexed(users, func(index int, _ option.TUICUser) int { + return index + }), common.Map(users, func(it option.TUICUser) [16]byte { + return [16]byte(uuid.Must(uuid.FromString(it.UUID)).Bytes()) + }), common.Map(users, func(it option.TUICUser) string { + return it.Password + })) +} diff --git a/protocol/tunnel/client.go b/protocol/tunnel/client.go index 45255c1f..d00cdcbf 100644 --- a/protocol/tunnel/client.go +++ b/protocol/tunnel/client.go @@ -3,13 +3,13 @@ package tunnel import ( "context" "net" - "os" "time" "github.com/gofrs/uuid/v5" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/endpoint" "github.com/sagernet/sing-box/adapter/outbound" + sbUot "github.com/sagernet/sing-box/common/uot" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" @@ -18,6 +18,7 @@ import ( "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/uot" "github.com/sagernet/sing/service" ) @@ -27,12 +28,13 @@ func RegisterClientEndpoint(registry *endpoint.Registry) { type ClientEndpoint struct { outbound.Adapter - ctx context.Context - outbound adapter.Outbound - router adapter.ConnectionRouterEx - logger logger.ContextLogger - uuid uuid.UUID - key uuid.UUID + ctx context.Context + outbound adapter.Outbound + router adapter.ConnectionRouterEx + logger logger.ContextLogger + uuid uuid.UUID + key uuid.UUID + uotClient *uot.Client } func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TunnelClientEndpointOptions) (adapter.Endpoint, error) { @@ -45,9 +47,9 @@ func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.Co return nil, err } client := &ClientEndpoint{ - Adapter: outbound.NewAdapter(C.TypeTunnelClient, tag, []string{N.NetworkTCP}, []string{}), + Adapter: outbound.NewAdapter(C.TypeTunnelClient, tag, []string{N.NetworkTCP, N.NetworkUDP}, []string{}), ctx: ctx, - router: router, + router: sbUot.NewRouter(router, logger), logger: logger, uuid: clientUUID, key: clientKey, @@ -58,6 +60,10 @@ func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.Co return nil, err } client.outbound = outbound + client.uotClient = &uot.Client{ + Dialer: outbound, + Version: uot.Version, + } return client, nil } @@ -85,8 +91,8 @@ func (c *ClientEndpoint) Start(stage adapter.StartStage) error { } func (c *ClientEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { - if network != N.NetworkTCP { - return nil, os.ErrInvalid + if N.NetworkName(network) == N.NetworkUDP { + return c.uotClient.DialContext(ctx, network, destination) } var destinationUUID *uuid.UUID if metadata := adapter.ContextFrom(ctx); metadata != nil { @@ -109,11 +115,14 @@ func (c *ClientEndpoint) DialContext(ctx context.Context, network string, destin return nil, err } err = WriteRequest(conn, &Request{UUID: c.key, Command: CommandTCP, DestinationUUID: *destinationUUID, Destination: destination}) - return conn, err + if err != nil { + return nil, err + } + return conn, nil } func (c *ClientEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { - return nil, os.ErrInvalid + return c.uotClient.ListenPacket(ctx, destination) } func (c *ClientEndpoint) Close() error { @@ -139,6 +148,7 @@ func (c *ClientEndpoint) startInboundConn() error { func (c *ClientEndpoint) connHandler(conn net.Conn, request *Request) { metadata := adapter.InboundContext{ + Inbound: c.Tag(), Source: M.ParseSocksaddr(conn.RemoteAddr().String()), Destination: request.Destination, } diff --git a/protocol/tunnel/server.go b/protocol/tunnel/server.go index d447be8c..a43254b7 100644 --- a/protocol/tunnel/server.go +++ b/protocol/tunnel/server.go @@ -3,14 +3,13 @@ package tunnel import ( "context" "net" - "os" - "sync" "time" "github.com/gofrs/uuid/v5" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/endpoint" "github.com/sagernet/sing-box/adapter/outbound" + sbUot "github.com/sagernet/sing-box/common/uot" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" @@ -19,6 +18,7 @@ import ( "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/uot" "github.com/sagernet/sing/service" ) @@ -28,16 +28,15 @@ func RegisterServerEndpoint(registry *endpoint.Registry) { type ServerEndpoint struct { outbound.Adapter - logger logger.ContextLogger - inbound adapter.Inbound - router adapter.Router - uuid uuid.UUID - users map[uuid.UUID]uuid.UUID - keys map[uuid.UUID]uuid.UUID - conns map[uuid.UUID]chan net.Conn - timeout time.Duration - - mtx sync.Mutex + logger logger.ContextLogger + inbound adapter.Inbound + router adapter.ConnectionRouterEx + uuid uuid.UUID + users map[uuid.UUID]uuid.UUID + keys map[uuid.UUID]uuid.UUID + conns map[uuid.UUID]chan net.Conn + timeout time.Duration + uotClient *uot.Client } func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TunnelServerEndpointOptions) (adapter.Endpoint, error) { @@ -46,9 +45,9 @@ func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.Co return nil, err } server := &ServerEndpoint{ - Adapter: outbound.NewAdapter(C.TypeTunnelServer, tag, []string{N.NetworkTCP}, []string{}), + Adapter: outbound.NewAdapter(C.TypeTunnelServer, tag, []string{N.NetworkTCP, N.NetworkUDP}, []string{}), logger: logger, - router: router, + router: sbUot.NewRouter(router, logger), uuid: serverUUID, } inboundRegistry := service.FromContext[adapter.InboundRegistry](ctx) @@ -78,6 +77,10 @@ func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.Co } else { server.timeout = C.TCPConnectTimeout } + server.uotClient = &uot.Client{ + Dialer: server, + Version: uot.Version, + } return server, nil } @@ -86,8 +89,8 @@ func (s *ServerEndpoint) Start(stage adapter.StartStage) error { } func (s *ServerEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { - if network != N.NetworkTCP { - return nil, os.ErrInvalid + if N.NetworkName(network) == N.NetworkUDP { + return s.uotClient.DialContext(ctx, network, destination) } var sourceUUID *uuid.UUID var ch chan net.Conn @@ -97,13 +100,11 @@ func (s *ServerEndpoint) DialContext(ctx context.Context, network string, destin if err != nil { return nil, err } - s.mtx.Lock() var ok bool ch, ok = s.conns[tunnelDestination] if !ok { return nil, E.New("user ", metadata.TunnelDestination, " not found") } - s.mtx.Unlock() } if metadata.TunnelSource != "" { tunnelSource, err := uuid.FromString(metadata.TunnelSource) @@ -131,6 +132,7 @@ func (s *ServerEndpoint) DialContext(ctx context.Context, network string, destin case conn := <-ch: err := WriteRequest(conn, &Request{UUID: *sourceUUID, Command: CommandTCP, Destination: destination}) if err != nil { + conn.Close() s.logger.ErrorContext(ctx, err) continue } @@ -142,7 +144,7 @@ func (s *ServerEndpoint) DialContext(ctx context.Context, network string, destin } func (s *ServerEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { - return nil, os.ErrInvalid + return s.uotClient.ListenPacket(ctx, destination) } func (s *ServerEndpoint) Close() error { @@ -159,8 +161,6 @@ func (s *ServerEndpoint) connHandler(ctx context.Context, conn net.Conn, metadat return err } if request.Command == CommandInbound { - s.mtx.Lock() - defer s.mtx.Unlock() uuid, ok := s.users[request.UUID] if !ok { return E.New("key ", request.UUID.String(), " not found") @@ -183,14 +183,12 @@ func (s *ServerEndpoint) connHandler(ctx context.Context, conn net.Conn, metadat if sourceUUID == request.DestinationUUID { return E.New("routing loop on ", sourceUUID) } - s.mtx.Lock() if request.DestinationUUID != s.uuid { _, ok = s.keys[request.DestinationUUID] if !ok { - return E.New("user ", sourceUUID, " not found") + return E.New("user ", request.DestinationUUID, " not found") } } - s.mtx.Unlock() metadata.Inbound = s.Tag() metadata.InboundType = C.TypeTunnelServer metadata.Destination = request.Destination diff --git a/protocol/vless/inbound.go b/protocol/vless/inbound.go index 3cc53db4..96f9abbd 100644 --- a/protocol/vless/inbound.go +++ b/protocol/vless/inbound.go @@ -138,6 +138,17 @@ func (h *Inbound) Close() error { ) } +func (h *Inbound) UpdateUsers(users []option.VLESSUser) { + h.users = users + h.service.UpdateUsers(common.MapIndexed(users, func(index int, _ option.VLESSUser) int { + return index + }), common.Map(users, func(it option.VLESSUser) string { + return it.UUID + }), common.Map(users, func(it option.VLESSUser) string { + return it.Flow + })) +} + func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { if h.tlsConfig != nil && h.transport == nil { tlsConn, err := tls.ServerHandshake(ctx, conn, h.tlsConfig) diff --git a/protocol/vmess/inbound.go b/protocol/vmess/inbound.go index 059d4775..9c7b4cc1 100644 --- a/protocol/vmess/inbound.go +++ b/protocol/vmess/inbound.go @@ -153,6 +153,16 @@ func (h *Inbound) Close() error { ) } +func (h *Inbound) UpdateUsers(users []option.VMessUser) { + h.service.UpdateUsers(common.MapIndexed(users, func(index int, _ option.VMessUser) int { + return index + }), common.Map(users, func(it option.VMessUser) string { + return it.UUID + }), common.Map(users, func(it option.VMessUser) int { + return it.AlterId + })) +} + func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { if h.tlsConfig != nil && h.transport == nil { tlsConn, err := tls.ServerHandshake(ctx, conn, h.tlsConfig) diff --git a/protocol/wireguard/endpoint_warp.go b/protocol/wireguard/endpoint_warp.go index 690ffb66..d44e3f34 100644 --- a/protocol/wireguard/endpoint_warp.go +++ b/protocol/wireguard/endpoint_warp.go @@ -154,6 +154,8 @@ func NewWARPEndpoint(ctx context.Context, router adapter.Router, logger log.Cont netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, + PersistentKeepaliveInterval: options.PersistentKeepaliveInterval, + Reserved: options.Reserved, }, }, MTU: 1280, diff --git a/route/conn.go b/route/conn.go index 18d54c45..73f765a8 100644 --- a/route/conn.go +++ b/route/conn.go @@ -14,7 +14,7 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/dialer" - "github.com/sagernet/sing-box/common/tlsfragment" + tf "github.com/sagernet/sing-box/common/tlsfragment" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/buf" @@ -303,7 +303,7 @@ func (m *ConnectionManager) connectionCopy(ctx context.Context, source net.Conn, } else { if err == nil { m.logger.DebugContext(ctx, "connection download finished") - } else if !E.IsClosedOrCanceled(err) && !strings.Contains(err.Error(), "NO_ERROR") { + } else if !E.IsClosedOrCanceled(err) && !strings.Contains(err.Error(), "NO_ERROR") && !strings.Contains(err.Error(), "response body closed") { m.logger.ErrorContext(ctx, "connection download closed: ", err) } else { m.logger.TraceContext(ctx, "connection download closed") diff --git a/route/route.go b/route/route.go index d9cb5f5e..103da350 100644 --- a/route/route.go +++ b/route/route.go @@ -15,8 +15,8 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" R "github.com/sagernet/sing-box/route/rule" - "github.com/sagernet/sing-mux" - "github.com/sagernet/sing-vmess" + mux "github.com/sagernet/sing-mux" + vmess "github.com/sagernet/sing-vmess" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/buf" "github.com/sagernet/sing/common/bufio" @@ -123,12 +123,11 @@ func (r *Router) routeConnection(ctx context.Context, conn net.Conn, metadata ad } } if selectedRule == nil { - defaultOutbound := r.outbound.Default() - if !common.Contains(defaultOutbound.Network(), N.NetworkTCP) { + if !common.Contains(r.defaultOutbound.Network(), N.NetworkTCP) { buf.ReleaseMulti(buffers) - return E.New("TCP is not supported by default outbound: ", defaultOutbound.Tag()) + return E.New("TCP is not supported by default outbound: ", r.defaultOutbound.Tag()) } - selectedOutbound = defaultOutbound + selectedOutbound = r.defaultOutbound } for _, buffer := range buffers { @@ -234,12 +233,11 @@ func (r *Router) routePacketConnection(ctx context.Context, conn N.PacketConn, m } } if selectedRule == nil || selectReturn { - defaultOutbound := r.outbound.Default() - if !common.Contains(defaultOutbound.Network(), N.NetworkUDP) { + if !common.Contains(r.defaultOutbound.Network(), N.NetworkUDP) { N.ReleaseMultiPacketBuffer(packetBuffers) - return E.New("UDP is not supported by outbound: ", defaultOutbound.Tag()) + return E.New("UDP is not supported by outbound: ", r.defaultOutbound.Tag()) } - selectedOutbound = defaultOutbound + selectedOutbound = r.defaultOutbound } for _, buffer := range packetBuffers { conn = bufio.NewCachedPacketConn(conn, buffer.Buffer, buffer.Destination) diff --git a/route/router.go b/route/router.go index ae2ecb55..f329b030 100644 --- a/route/router.go +++ b/route/router.go @@ -30,7 +30,9 @@ type Router struct { dnsTransport adapter.DNSTransportManager connection adapter.ConnectionManager network adapter.NetworkManager + defaultOutbound adapter.Outbound rules []adapter.Rule + final string needFindProcess bool ruleSets []adapter.RuleSet ruleSetMap map[string]adapter.RuleSet @@ -53,6 +55,7 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.Route connection: service.FromContext[adapter.ConnectionManager](ctx), network: service.FromContext[adapter.NetworkManager](ctx), rules: make([]adapter.Rule, 0, len(options.Rules)), + final: options.Final, ruleSetMap: make(map[string]adapter.RuleSet), needFindProcess: hasRule(options.Rules, isProcessRule) || hasDNSRule(dnsOptions.Rules, isProcessDNSRule) || options.FindProcess, pauseManager: service.FromContext[pause.Manager](ctx), @@ -159,6 +162,15 @@ func (r *Router) Start(stage adapter.StartStage) error { return E.Cause(err, "post start rule_set[", ruleSet.Name(), "]") } } + if r.final != "" { + defaultOutbound, loaded := r.outbound.Outbound(r.final) + if !loaded { + return E.New("outbound not found: ", r.final) + } + r.defaultOutbound = defaultOutbound + } else { + r.defaultOutbound = r.outbound.Default() + } r.started = true return nil case adapter.StartStateStarted: diff --git a/service/admin_panel/migration/postgresql.go b/service/admin_panel/migration/postgresql.go new file mode 100644 index 00000000..3ff8c26f --- /dev/null +++ b/service/admin_panel/migration/postgresql.go @@ -0,0 +1,400 @@ +package migration + +import ( + "database/sql" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/postgres" + "github.com/sagernet/sing-box/common/migrate/source" +) + +var migrations = map[string]string{ + "1_initialize_schema.up.sql": ` + SET statement_timeout = 0; + SET lock_timeout = 0; + SET idle_in_transaction_session_timeout = 0; + SET client_encoding = 'UTF8'; + SET standard_conforming_strings = on; + SELECT pg_catalog.set_config('search_path', '', false); + SET check_function_bodies = false; + SET xmloption = content; + SET client_min_messages = warning; + SET row_security = off; + + CREATE SEQUENCE public.goadmin_menu_myid_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + MAXVALUE 99999999 + CACHE 1; + + + SET default_tablespace = ''; + + SET default_table_access_method = heap; + + CREATE TABLE public.goadmin_menu ( + id integer DEFAULT nextval('public.goadmin_menu_myid_seq'::regclass) NOT NULL, + parent_id integer DEFAULT 0 NOT NULL, + type integer DEFAULT 0, + "order" integer DEFAULT 0 NOT NULL, + title character varying(50) NOT NULL, + header character varying(100), + plugin_name character varying(100) NOT NULL, + icon character varying(50) NOT NULL, + uri character varying(3000) NOT NULL, + uuid character varying(100), + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() + ); + + CREATE SEQUENCE public.goadmin_operation_log_myid_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + MAXVALUE 99999999 + CACHE 1; + + CREATE TABLE public.goadmin_operation_log ( + id integer DEFAULT nextval('public.goadmin_operation_log_myid_seq'::regclass) NOT NULL, + user_id integer NOT NULL, + path character varying(255) NOT NULL, + method character varying(10) NOT NULL, + ip character varying(15) NOT NULL, + input text NOT NULL, + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() + ); + + CREATE SEQUENCE public.goadmin_permissions_myid_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + MAXVALUE 99999999 + CACHE 1; + + CREATE TABLE public.goadmin_permissions ( + id integer DEFAULT nextval('public.goadmin_permissions_myid_seq'::regclass) NOT NULL, + name character varying(50) NOT NULL, + slug character varying(50) NOT NULL, + http_method character varying(255), + http_path text NOT NULL, + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() + ); + + CREATE TABLE public.goadmin_role_menu ( + role_id integer NOT NULL, + menu_id integer NOT NULL, + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() + ); + + CREATE TABLE public.goadmin_role_permissions ( + role_id integer NOT NULL, + permission_id integer NOT NULL, + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() + ); + + CREATE TABLE public.goadmin_role_users ( + role_id integer NOT NULL, + user_id integer NOT NULL, + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() + ); + + CREATE SEQUENCE public.goadmin_roles_myid_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + MAXVALUE 99999999 + CACHE 1; + + CREATE TABLE public.goadmin_roles ( + id integer DEFAULT nextval('public.goadmin_roles_myid_seq'::regclass) NOT NULL, + name character varying NOT NULL, + slug character varying NOT NULL, + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() + ); + + CREATE SEQUENCE public.goadmin_session_myid_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + MAXVALUE 99999999 + CACHE 1; + + CREATE TABLE public.goadmin_session ( + id integer DEFAULT nextval('public.goadmin_session_myid_seq'::regclass) NOT NULL, + sid character varying(50) NOT NULL, + "values" character varying(3000) NOT NULL, + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() + ); + + CREATE SEQUENCE public.goadmin_site_myid_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + MAXVALUE 99999999 + CACHE 1; + + CREATE TABLE public.goadmin_site ( + id integer DEFAULT nextval('public.goadmin_site_myid_seq'::regclass) NOT NULL, + key character varying(100) NOT NULL, + value text NOT NULL, + type integer DEFAULT 0, + description character varying(3000), + state integer DEFAULT 0, + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() + ); + + CREATE TABLE public.goadmin_user_permissions ( + user_id integer NOT NULL, + permission_id integer NOT NULL, + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() + ); + + CREATE SEQUENCE public.goadmin_users_myid_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + MAXVALUE 99999999 + CACHE 1; + + CREATE TABLE public.goadmin_users ( + id integer DEFAULT nextval('public.goadmin_users_myid_seq'::regclass) NOT NULL, + username character varying(100) NOT NULL, + password character varying(100) NOT NULL, + name character varying(100) NOT NULL, + avatar character varying(255), + remember_token character varying(100), + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() + ); + + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (1, 0, 1, 1, 'Dashboard', NULL, '', 'fa-bar-chart', '/', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (2, 0, 1, 2, 'Admin', NULL, '', 'fa-tasks', '', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (3, 2, 1, 2, 'Users', NULL, '', 'fa-users', '/info/manager', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (4, 2, 1, 3, 'Roles', NULL, '', 'fa-user', '/info/roles', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (5, 2, 1, 4, 'Permission', NULL, '', 'fa-ban', '/info/permission', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (7, 2, 1, 6, 'Operation log', NULL, '', 'fa-history', '/info/op', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (9, 0, 0, 9, 'Users', '', '', 'fa-users', '/info/users', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (14, 0, 0, 12, 'Github', 'Miscellaneous', '', 'fa-github', 'https://github.com/shtorm-7/sing-box-extended', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (15, 0, 0, 13, 'Donate', '', '', 'fa-heart', 'https://github.com/shtorm-7/sing-box-extended#support-the-project', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (13, 0, 0, 7, 'Squads', 'General', '', 'fa-gg', '/info/squads', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (11, 0, 0, 8, 'Nodes', '', '', 'fa-sitemap', '/info/nodes', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (10, 0, 0, 10, 'Connection limiters', 'Limiters', '', 'fa-plug', '/info/connection_limiters', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (8, 0, 0, 11, 'Bandwidth limiters', '', '', 'fa-dashboard', '/info/bandwidth_limiters', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + + + INSERT INTO public.goadmin_permissions (id, name, slug, http_method, http_path, created_at, updated_at) VALUES (1, 'All permission', '*', '', '*', '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_permissions (id, name, slug, http_method, http_path, created_at, updated_at) VALUES (2, 'Dashboard', 'dashboard', 'GET,PUT,POST,DELETE', '/', '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + + + INSERT INTO public.goadmin_role_menu (role_id, menu_id, created_at, updated_at) VALUES (1, 1, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_role_menu (role_id, menu_id, created_at, updated_at) VALUES (1, 7, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_role_menu (role_id, menu_id, created_at, updated_at) VALUES (2, 7, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + + + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (1, 1, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (1, 2, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (2, 2, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + + + INSERT INTO public.goadmin_role_users (role_id, user_id, created_at, updated_at) VALUES (1, 1, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_role_users (role_id, user_id, created_at, updated_at) VALUES (2, 2, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + + + INSERT INTO public.goadmin_roles (id, name, slug, created_at, updated_at) VALUES (1, 'Administrator', 'administrator', '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_roles (id, name, slug, created_at, updated_at) VALUES (2, 'Operator', 'operator', '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + + + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (6, 'site_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.436501', '2026-02-15 09:57:02.436501'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (7, 'prohibit_config_modification', 'false', 0, NULL, 1, '2026-02-15 09:57:02.441183', '2026-02-15 09:57:02.441183'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (11, 'login_url', '/login', 0, NULL, 1, '2026-02-15 09:57:02.459525', '2026-02-15 09:57:02.459525'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (16, 'open_admin_api', 'false', 0, NULL, 1, '2026-02-15 09:57:02.483908', '2026-02-15 09:57:02.483908'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (18, 'domain', '', 0, NULL, 1, '2026-02-15 09:57:02.493151', '2026-02-15 09:57:02.493151'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (23, 'asset_root_path', './public/', 0, NULL, 1, '2026-02-15 09:57:02.517213', '2026-02-15 09:57:02.517213'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (24, 'url_prefix', 'admin', 0, NULL, 1, '2026-02-15 09:57:02.521815', '2026-02-15 09:57:02.521815'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (33, 'exclude_theme_components', 'null', 0, NULL, 1, '2026-02-15 09:57:02.565725', '2026-02-15 09:57:02.565725'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (39, 'app_id', 'Qn0eh7HQsrt9', 0, NULL, 1, '2026-02-15 09:57:02.592551', '2026-02-15 09:57:02.592551'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (41, 'auth_user_table', 'goadmin_users', 0, NULL, 1, '2026-02-15 09:57:02.601496', '2026-02-15 09:57:02.601496'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (53, 'bootstrap_file_path', '', 0, NULL, 1, '2026-02-15 09:57:02.658984', '2026-02-15 09:57:02.658984'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (55, 'index_url', '/', 0, NULL, 1, '2026-02-15 09:57:02.668457', '2026-02-15 09:57:02.668457'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (66, 'login_logo', '', 0, NULL, 1, '2026-02-15 09:57:02.719608', '2026-02-15 09:57:02.719608'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (67, 'hide_visitor_user_center_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.724307', '2026-02-15 09:57:02.724307'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (68, 'go_mod_file_path', '', 0, NULL, 1, '2026-02-15 09:57:02.728694', '2026-02-15 09:57:02.728694'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (3, 'logger_encoder_caller', 'full', 0, NULL, 1, '2026-02-15 09:57:02.420312', '2026-02-15 09:57:02.420312'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (60, 'logger_encoder_caller_key', 'caller', 0, NULL, 1, '2026-02-15 09:57:02.692189', '2026-02-15 09:57:02.692189'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (34, 'logo', 'Sing-box Extended', 0, NULL, 1, '2026-02-15 09:57:02.570594', '2026-02-15 09:57:02.570594'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (69, 'env', 'prod', 0, NULL, 1, '2026-02-15 09:57:02.733059', '2026-02-15 09:57:02.733059'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (29, 'color_scheme', 'skin-black', 0, NULL, 1, '2026-02-15 09:57:02.545599', '2026-02-15 09:57:02.545599'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (17, 'allow_del_operation_log', 'false', 0, NULL, 1, '2026-02-15 09:57:02.488458', '2026-02-15 09:57:02.488458'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (35, 'info_log_path', '', 0, NULL, 1, '2026-02-15 09:57:02.574649', '2026-02-15 09:57:02.574649'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (22, 'operation_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.512394', '2026-02-15 09:57:02.512394'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (42, 'hide_app_info_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.606071', '2026-02-15 09:57:02.606071'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (12, 'access_log_path', '', 0, NULL, 1, '2026-02-15 09:57:02.464612', '2026-02-15 09:57:02.464612'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (32, 'logger_rotate_max_age', '30', 0, NULL, 1, '2026-02-15 09:57:02.560801', '2026-02-15 09:57:02.560801'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (40, 'custom_foot_html', '', 0, NULL, 1, '2026-02-15 09:57:02.597285', '2026-02-15 09:57:02.597285'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (62, 'logger_encoder_duration', 'string', 0, NULL, 1, '2026-02-15 09:57:02.701522', '2026-02-15 09:57:02.701522'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (65, 'logger_encoder_level_key', 'level', 0, NULL, 1, '2026-02-15 09:57:02.715108', '2026-02-15 09:57:02.715108'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (64, 'debug', 'false', 0, NULL, 1, '2026-02-15 09:57:02.710705', '2026-02-15 09:57:02.710705'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (43, 'hide_plugin_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.610825', '2026-02-15 09:57:02.610825'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (54, 'animation_type', '', 0, NULL, 1, '2026-02-15 09:57:02.663713', '2026-02-15 09:57:02.663713'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (48, 'theme', 'sword', 0, NULL, 1, '2026-02-15 09:57:02.634039', '2026-02-15 09:57:02.634039'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (45, 'info_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.620165', '2026-02-15 09:57:02.620165'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (31, 'error_log_path', '', 0, NULL, 1, '2026-02-15 09:57:02.555798', '2026-02-15 09:57:02.555798'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (5, 'asset_url', '', 0, NULL, 1, '2026-02-15 09:57:02.431855', '2026-02-15 09:57:02.431855'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (36, 'logger_encoder_encoding', 'console', 0, NULL, 1, '2026-02-15 09:57:02.579052', '2026-02-15 09:57:02.579052'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (27, 'login_title', 'Sing-box Extended', 0, NULL, 1, '2026-02-15 09:57:02.536102', '2026-02-15 09:57:02.536102'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (51, 'animation_duration', '0.00', 0, NULL, 1, '2026-02-15 09:57:02.64867', '2026-02-15 09:57:02.64867'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (19, 'file_upload_engine', '{"name":"local"}', 0, NULL, 1, '2026-02-15 09:57:02.49794', '2026-02-15 09:57:02.49794'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (26, 'logger_encoder_time', 'iso8601', 0, NULL, 1, '2026-02-15 09:57:02.531365', '2026-02-15 09:57:02.531365'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (10, 'custom_404_html', '', 0, NULL, 1, '2026-02-15 09:57:02.454777', '2026-02-15 09:57:02.454777'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (58, 'sql_log', 'false', 0, NULL, 1, '2026-02-15 09:57:02.682567', '2026-02-15 09:57:02.682567'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (2, 'logger_encoder_message_key', 'msg', 0, NULL, 1, '2026-02-15 09:57:02.415189', '2026-02-15 09:57:02.415189'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (46, 'logger_encoder_stacktrace_key', 'stacktrace', 0, NULL, 1, '2026-02-15 09:57:02.624977', '2026-02-15 09:57:02.624977'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (63, 'mini_logo', 'SBE', 0, NULL, 1, '2026-02-15 09:57:02.706145', '2026-02-15 09:57:02.706145'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (38, 'custom_403_html', '', 0, NULL, 1, '2026-02-15 09:57:02.588062', '2026-02-15 09:57:02.588062'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (30, 'language', 'en', 0, NULL, 1, '2026-02-15 09:57:02.550466', '2026-02-15 09:57:02.550466'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (15, 'hide_config_center_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.479097', '2026-02-15 09:57:02.479097'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (59, 'logger_rotate_max_backups', '5', 0, NULL, 1, '2026-02-15 09:57:02.687429', '2026-02-15 09:57:02.687429'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (57, 'custom_head_html', '', 0, NULL, 1, '2026-02-15 09:57:02.677723', '2026-02-15 09:57:02.677723'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (52, 'custom_500_html', '', 0, NULL, 1, '2026-02-15 09:57:02.654236', '2026-02-15 09:57:02.654236'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (44, 'title', 'Sing-box Extended', 0, NULL, 1, '2026-02-15 09:57:02.615471', '2026-02-15 09:57:02.615471'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (47, 'session_life_time', '7200', 0, NULL, 1, '2026-02-15 09:57:02.629619', '2026-02-15 09:57:02.629619'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (8, 'access_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.445593', '2026-02-15 09:57:02.445593'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (49, 'error_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.6385', '2026-02-15 09:57:02.6385'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (50, 'logger_rotate_max_size', '10', 0, NULL, 1, '2026-02-15 09:57:02.643733', '2026-02-15 09:57:02.643733'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (14, 'logger_rotate_compress', 'false', 0, NULL, 1, '2026-02-15 09:57:02.474296', '2026-02-15 09:57:02.474296'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (13, 'logger_encoder_time_key', 'ts', 0, NULL, 1, '2026-02-15 09:57:02.469396', '2026-02-15 09:57:02.469396'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (37, 'animation_delay', '0.00', 0, NULL, 1, '2026-02-15 09:57:02.583815', '2026-02-15 09:57:02.583815'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (20, 'extra', '', 0, NULL, 1, '2026-02-15 09:57:02.50276', '2026-02-15 09:57:02.50276'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (25, 'access_assets_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.526618', '2026-02-15 09:57:02.526618'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (4, 'logger_level', '0', 0, NULL, 1, '2026-02-15 09:57:02.426736', '2026-02-15 09:57:02.426736'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (9, 'footer_info', '', 0, NULL, 1, '2026-02-15 09:57:02.450409', '2026-02-15 09:57:02.450409'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (21, 'no_limit_login_ip', 'false', 0, NULL, 1, '2026-02-15 09:57:02.507609', '2026-02-15 09:57:02.507609'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (28, 'hide_tool_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.540813', '2026-02-15 09:57:02.540813'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (61, 'logger_encoder_level', 'capitalColor', 0, NULL, 1, '2026-02-15 09:57:02.696859', '2026-02-15 09:57:02.696859'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (56, 'logger_encoder_name_key', 'logger', 0, NULL, 1, '2026-02-15 09:57:02.672962', '2026-02-15 09:57:02.672962'); + + + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (1, 1, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (2, 2, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + + + INSERT INTO public.goadmin_users (id, username, password, name, avatar, remember_token, created_at, updated_at) VALUES (2, 'operator', '$2a$10$rVqkOzHjN2MdlEprRflb1eGP0oZXuSrbJLOmJagFsCd81YZm0bsh.', 'Operator', '', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_users (id, username, password, name, avatar, remember_token, created_at, updated_at) VALUES (1, 'admin', '$2a$10$ilNHHnX5S6EMw.Ffc1Y1JezYCyquFIO.7Z0vLr1eHJUXnGy4cdrtq', 'admin', '', 'tlNcBVK9AvfYH7WEnwB1RKvocJu8FfRy4um3DJtwdHuJy0dwFsLOgAc0xUfh', '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + + + SELECT pg_catalog.setval('public.goadmin_menu_myid_seq', 12, true); + + + SELECT pg_catalog.setval('public.goadmin_operation_log_myid_seq', 11, true); + + + SELECT pg_catalog.setval('public.goadmin_permissions_myid_seq', 2, true); + + + SELECT pg_catalog.setval('public.goadmin_roles_myid_seq', 2, true); + + + SELECT pg_catalog.setval('public.goadmin_session_myid_seq', 7, true); + + + SELECT pg_catalog.setval('public.goadmin_site_myid_seq', 69, true); + + + SELECT pg_catalog.setval('public.goadmin_users_myid_seq', 2, true); + + + ALTER TABLE ONLY public.goadmin_menu + ADD CONSTRAINT goadmin_menu_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.goadmin_operation_log + ADD CONSTRAINT goadmin_operation_log_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.goadmin_permissions + ADD CONSTRAINT goadmin_permissions_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.goadmin_roles + ADD CONSTRAINT goadmin_roles_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.goadmin_session + ADD CONSTRAINT goadmin_session_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.goadmin_site + ADD CONSTRAINT goadmin_site_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.goadmin_users + ADD CONSTRAINT goadmin_users_pkey PRIMARY KEY (id); + `, + "1_initialize_schema.down.sql": ``, +} + +func MigratePostgreSQL(db *sql.DB) error { + driver, err := postgres.WithInstance(db, &postgres.Config{}) + if err != nil { + return err + } + + sourceDriver := source.NewRawDriver(migrations) + if err := sourceDriver.Init(); err != nil { + return err + } + + m, err := migrate.NewWithInstance( + "raw", + sourceDriver, + "postgres", + driver, + ) + if err != nil { + return err + } + + return m.Up() +} diff --git a/service/admin_panel/pages/dashboard.go b/service/admin_panel/pages/dashboard.go new file mode 100644 index 00000000..1782f750 --- /dev/null +++ b/service/admin_panel/pages/dashboard.go @@ -0,0 +1,13 @@ +package pages + +import ( + "github.com/GoAdminGroup/go-admin/context" + "github.com/GoAdminGroup/go-admin/template/types" +) + +func DashboardPage(ctx *context.Context) (types.Panel, error) { + + return types.Panel{ + Title: "Dashboard", + }, nil +} diff --git a/service/admin_panel/service.go b/service/admin_panel/service.go new file mode 100644 index 00000000..4fda623d --- /dev/null +++ b/service/admin_panel/service.go @@ -0,0 +1,188 @@ +//go:build with_admin_panel + +package admin_panel + +import ( + "context" + "database/sql" + "errors" + "net/http" + + "github.com/go-chi/chi" + "github.com/golang-migrate/migrate/v4" + _ "github.com/lib/pq" + "golang.org/x/net/http2" + + _ "github.com/GoAdminGroup/go-admin/adapter/chi" + "github.com/GoAdminGroup/go-admin/engine" + "github.com/GoAdminGroup/go-admin/modules/config" + _ "github.com/GoAdminGroup/go-admin/modules/db/drivers/sqlite" + "github.com/GoAdminGroup/go-admin/plugins/admin/modules/table" + "github.com/GoAdminGroup/go-admin/template" + "github.com/GoAdminGroup/go-admin/template/chartjs" + _ "github.com/GoAdminGroup/themes/adminlte" + _ "github.com/GoAdminGroup/themes/sword" + + "github.com/sagernet/sing-box/adapter" + boxService "github.com/sagernet/sing-box/adapter/service" + "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/service/admin_panel/migration" + "github.com/sagernet/sing-box/service/admin_panel/pages" + "github.com/sagernet/sing-box/service/admin_panel/tables" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + N "github.com/sagernet/sing/common/network" + aTLS "github.com/sagernet/sing/common/tls" + "github.com/sagernet/sing/service" +) + +func RegisterService(registry *boxService.Registry) { + boxService.Register[option.AdminPanelServiceOptions](registry, C.TypeAdminPanel, NewService) +} + +type Service struct { + boxService.Adapter + ctx context.Context + logger log.ContextLogger + listener *listener.Listener + tlsConfig tls.ServerConfig + httpServer *http.Server + options option.AdminPanelServiceOptions +} + +func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.AdminPanelServiceOptions) (adapter.Service, error) { + s := &Service{ + Adapter: boxService.NewAdapter(C.TypeAdminPanel, tag), + ctx: ctx, + logger: logger, + listener: listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkTCP}, + Listen: options.ListenOptions, + }), + options: options, + } + return s, nil +} + +func (s *Service) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + boxManager := service.FromContext[adapter.ServiceManager](s.ctx) + service, ok := boxManager.Get(s.options.Manager) + if !ok { + return E.New("manager ", s.options.Manager, " not found") + } + manager, ok := service.(CM.Manager) + if !ok { + return E.New("invalid ", s.options.Manager, " manager") + } + switch s.options.Database.Driver { + case "postgresql": + db, err := sql.Open("postgres", s.options.Database.DSN) + if err != nil { + return err + } + defer db.Close() + if err := migration.MigratePostgreSQL(db); err != nil && err != migrate.ErrNoChange { + return err + } + default: + return E.New("unknown driver \"", s.options.Database.Driver, "\"") + } + var generators = map[string]table.Generator{ + "squads": tables.SquadTableFactory( + manager, + s.logger, + ), + "nodes": tables.NodeTableFactory( + manager, + s.logger, + ), + "users": tables.UserTableFactory( + manager, + s.logger, + ), + "connection_limiters": tables.ConnectionLimiterTableFactory( + manager, + s.logger, + ), + "bandwidth_limiters": tables.BandwidthLimiterTableFactory( + manager, + s.logger, + ), + } + eng := engine.Default() + chiRouter := chi.NewRouter() + template.AddComp(chartjs.NewChart()) + if err := eng.AddConfig(&config.Config{ + UrlPrefix: "admin", + IndexUrl: "/", + LoginUrl: "/login", + Databases: config.DatabaseList{ + "default": config.Database{ + Driver: s.options.Database.Driver, + Dsn: s.options.Database.DSN, + }, + }, + }). + AddGenerators(generators). + Use(chiRouter); err != nil { + return err + } + eng.HTML("GET", "/admin", pages.DashboardPage) + chiRouter.Get("/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/admin", http.StatusMovedPermanently) + }) + chiRouter.Get("/admin/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/admin", http.StatusMovedPermanently) + }) + if s.options.TLS != nil { + tlsConfig, err := tls.NewServer(s.ctx, s.logger, common.PtrValueOrDefault(s.options.TLS)) + if err != nil { + return err + } + s.tlsConfig = tlsConfig + } + if s.tlsConfig != nil { + err := s.tlsConfig.Start() + if err != nil { + return E.Cause(err, "create TLS config") + } + } + tcpListener, err := s.listener.ListenTCP() + if err != nil { + return err + } + if s.tlsConfig != nil { + if !common.Contains(s.tlsConfig.NextProtos(), http2.NextProtoTLS) { + s.tlsConfig.SetNextProtos(append([]string{"h2"}, s.tlsConfig.NextProtos()...)) + } + tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig) + } + s.httpServer = &http.Server{ + Handler: chiRouter, + } + go func() { + err = s.httpServer.Serve(tcpListener) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + s.logger.Error("serve error: ", err) + } + }() + return nil +} + +func (s *Service) Close() error { + return common.Close( + common.PtrOrNil(s.httpServer), + common.PtrOrNil(s.listener), + s.tlsConfig, + ) +} diff --git a/service/admin_panel/service_stub.go b/service/admin_panel/service_stub.go new file mode 100644 index 00000000..f6a4b15c --- /dev/null +++ b/service/admin_panel/service_stub.go @@ -0,0 +1,20 @@ +//go:build !with_admin_panel + +package admin_panel + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/service" + 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 RegisterService(registry *service.Registry) { + service.Register[option.AdminPanelServiceOptions](registry, C.TypeAdminPanel, func(ctx context.Context, logger log.ContextLogger, tag string, options option.AdminPanelServiceOptions) (adapter.Service, error) { + return nil, E.New(`Admin panel is not included in this build, rebuild with -tags with_admin_panel`) + }) +} diff --git a/service/admin_panel/tables/bandwidth_limiter.go b/service/admin_panel/tables/bandwidth_limiter.go new file mode 100644 index 00000000..c35d8452 --- /dev/null +++ b/service/admin_panel/tables/bandwidth_limiter.go @@ -0,0 +1,259 @@ +package tables + +import ( + "encoding/json" + "strconv" + "strings" + "time" + + "github.com/GoAdminGroup/go-admin/context" + "github.com/GoAdminGroup/go-admin/modules/db" + mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form" + "github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter" + "github.com/GoAdminGroup/go-admin/plugins/admin/modules/table" + "github.com/GoAdminGroup/go-admin/template" + "github.com/GoAdminGroup/go-admin/template/types" + "github.com/GoAdminGroup/go-admin/template/types/form" + + "github.com/sagernet/sing-box/log" + CM "github.com/sagernet/sing-box/service/manager/constant" +) + +func BandwidthLimiterTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) table.Table { + return func(ctx *context.Context) table.Table { + t := table.NewDefaultTable(ctx, table.Config{ + CanAdd: true, + Editable: true, + Deletable: true, + Exportable: true, + PrimaryKey: table.PrimaryKey{ + Type: db.Int, + Name: table.DefaultPrimaryKeyName, + }, + }) + squads, err := manager.GetSquads(map[string][]string{}) + if err != nil { + return nil + } + squadsByID := make(map[int]string, len(squads)) + squadOptions := make(types.FieldOptions, len(squads)) + for i, squad := range squads { + squadsByID[squad.ID] = squad.Name + squadOptions[i] = types.FieldOption{ + Text: squad.Name, + Value: strconv.Itoa(squad.ID), + } + } + info := t.GetInfo().SetFilterFormLayout(form.LayoutFilter) + info.AddField("ID", "id", db.Int). + FieldSortable() + info.AddField("Squads", "squad_ids", db.Varchar). + FieldDisplay(func(model types.FieldModel) interface{} { + values := model.Row["squad_ids"].([]interface{}) + labels := template.HTML("") + labelTpl := label(ctx).SetType("success") + labelValues := make([]string, len(values)) + for i, squadID := range values { + labelValues[i] = squadsByID[int(squadID.(float64))] + } + for key, label := range labelValues { + if key == len(labelValues)-1 { + labels += labelTpl.SetContent(template.HTML(label)).GetContent() + } else { + labels += labelTpl.SetContent(template.HTML(label)).GetContent() + } + } + return labels + }) + info.AddField("Username", "username", db.Varchar). + FieldFilterable(). + FieldSortable() + info.AddField("Outbound", "outbound", db.Varchar). + FieldFilterable(). + FieldSortable() + info.AddField("Strategy", "strategy", db.Varchar). + FieldFilterable(types.FilterType{ + FormType: form.SelectSingle, + Options: types.FieldOptions{ + {Text: "Connection", Value: "connection"}, + {Text: "Global", Value: "global"}, + }, + }). + FieldSortable() + info.AddField("Mode", "mode", db.Varchar). + FieldFilterable(types.FilterType{ + FormType: form.SelectSingle, + Options: types.FieldOptions{ + {Text: "Download", Value: "download"}, + {Text: "Upload", Value: "upload"}, + {Text: "Duplex", Value: "duplex"}, + }, + }). + FieldSortable() + info.AddField("Connection type", "connection_type", db.Varchar). + FieldFilterable(types.FilterType{ + FormType: form.SelectSingle, + Options: types.FieldOptions{ + {Text: "HWID", Value: "hwid"}, + {Text: "Mux", Value: "mux"}, + {Text: "IP", Value: "ip"}, + }, + }). + FieldSortable() + info.AddField("Speed", "speed", db.Varchar). + FieldSortable() + info.AddField("Created at", "created_at", db.Datetime). + FieldDisplay(func(model types.FieldModel) interface{} { + t, err := time.Parse(time.RFC3339, model.Value) + if err != nil { + return model.Value + } + return t.Format("2006-01-02 15:04:05") + }). + FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). + FieldSortable() + info.AddField("Updated at", "updated_at", db.Datetime). + FieldDisplay(func(model types.FieldModel) interface{} { + t, err := time.Parse(time.RFC3339, model.Value) + if err != nil { + return model.Value + } + return t.Format("2006-01-02 15:04:05") + }). + FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). + FieldSortable() + + info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) { + filters := make(map[string][]string) + listFilters := map[string][]string{ + "offset": {strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)}, + "limit": {param.PageSize}, + } + for k, v := range param.Fields { + if strings.HasPrefix(k, "__") { + continue + } + key := strings.TrimSuffix(k, "__goadmin") + filters[key] = v + listFilters[key] = v + } + if param.SortField != "" { + if param.SortType == "asc" { + listFilters["sort_asc"] = []string{param.SortField} + } else { + listFilters["sort_desc"] = []string{param.SortField} + } + } + items, err := manager.GetBandwidthLimiters(listFilters) + if err != nil { + logger.Error(err) + return nil, 0 + } + count, err := manager.GetBandwidthLimitersCount(filters) + if err != nil { + logger.Error(err) + return nil, 0 + } + result := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + var data map[string]interface{} + raw, _ := json.Marshal(item) + json.Unmarshal(raw, &data) + result = append(result, data) + } + return result, count + }) + + info.SetDeleteFn(func(ids []string) error { + for _, id := range ids { + i, err := strconv.Atoi(id) + if err != nil { + return err + } + if _, err := manager.DeleteBandwidthLimiter(i); err != nil { + return err + } + } + return nil + }) + + info.SetTable("bandwidth_limiters").SetTitle("Bandwidth Limiters").SetDescription("Bandwidth Limiters") + + formList := t.GetForm() + formList.AddField("ID", "id", db.Int, form.Default). + FieldNotAllowAdd(). + FieldNotAllowEdit() + formList.AddField("Squads", "squad_ids", db.Varchar, form.Select). + FieldMust(). + FieldOptions(squadOptions). + FieldDisableWhenUpdate() + formList.AddField("Username", "username", db.Varchar, form.Text). + FieldMust(). + FieldDisplayButCanNotEditWhenUpdate() + formList.AddField("Outbound", "outbound", db.Varchar, form.Text). + FieldMust(). + FieldDisplayButCanNotEditWhenUpdate() + formList.AddField("Strategy", "strategy", db.Varchar, form.SelectSingle). + FieldMust(). + FieldOptions(types.FieldOptions{ + {Text: "Connection", Value: "connection"}, + {Text: "Global", Value: "global"}, + }). + FieldOnChooseOptionsHide([]string{"", "global"}, "connection_type") + formList.AddField("Mode", "mode", db.Varchar, form.SelectSingle). + FieldMust(). + FieldOptions(types.FieldOptions{ + {Text: "Download", Value: "download"}, + {Text: "Upload", Value: "upload"}, + {Text: "Duplex", Value: "duplex"}, + }) + formList.AddField("Connection type", "connection_type", db.Varchar, form.SelectSingle). + FieldOptions(types.FieldOptions{ + {Text: "HWID", Value: "hwid"}, + {Text: "Mux", Value: "mux"}, + {Text: "IP", Value: "ip"}, + }) + formList.AddField("Speed", "speed", db.Varchar, form.Text). + FieldMust() + + formList.SetInsertFn(func(values mForm.Values) error { + squadIDs := make([]int, len(values["squad_ids[]"])) + for i, rawSquadID := range values["squad_ids[]"] { + squadID, err := strconv.Atoi(rawSquadID) + if err != nil { + return err + } + squadIDs[i] = squadID + } + _, err := manager.CreateBandwidthLimiter(CM.BandwidthLimiterCreate{ + SquadIDs: squadIDs, + Username: values.Get("username"), + Outbound: values.Get("outbound"), + Strategy: values.Get("strategy"), + Mode: values.Get("mode"), + ConnectionType: values.Get("connection_type"), + Speed: values.Get("speed"), + }) + return err + }) + + formList.SetUpdateFn(func(values mForm.Values) error { + id, err := strconv.Atoi(values.Get("id")) + if err != nil { + return err + } + _, err = manager.UpdateBandwidthLimiter(id, CM.BandwidthLimiterUpdate{ + Username: values.Get("username"), + Outbound: values.Get("outbound"), + Strategy: values.Get("strategy"), + Mode: values.Get("mode"), + ConnectionType: values.Get("connection_type"), + Speed: values.Get("speed"), + }) + return err + }) + + formList.SetTable("bandwidth_limiters").SetTitle("Bandwidth Limiters").SetDescription("Bandwidth Limiters") + return t + } +} diff --git a/service/admin_panel/tables/connection_limiter.go b/service/admin_panel/tables/connection_limiter.go new file mode 100644 index 00000000..66dd4f3d --- /dev/null +++ b/service/admin_panel/tables/connection_limiter.go @@ -0,0 +1,261 @@ +package tables + +import ( + "encoding/json" + "strconv" + "strings" + "time" + + "github.com/GoAdminGroup/go-admin/context" + "github.com/GoAdminGroup/go-admin/modules/db" + mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form" + "github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter" + "github.com/GoAdminGroup/go-admin/plugins/admin/modules/table" + "github.com/GoAdminGroup/go-admin/template" + "github.com/GoAdminGroup/go-admin/template/types" + "github.com/GoAdminGroup/go-admin/template/types/form" + + "github.com/sagernet/sing-box/log" + CM "github.com/sagernet/sing-box/service/manager/constant" +) + +func ConnectionLimiterTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) table.Table { + return func(ctx *context.Context) table.Table { + connectionLimiterTable := table.NewDefaultTable(ctx, table.Config{ + CanAdd: true, + Editable: true, + Deletable: true, + Exportable: true, + PrimaryKey: table.PrimaryKey{ + Type: db.Int, + Name: table.DefaultPrimaryKeyName, + }, + }) + squads, err := manager.GetSquads(map[string][]string{}) + if err != nil { + return nil + } + squadsByID := make(map[int]string, len(squads)) + squadOptions := make(types.FieldOptions, len(squads)) + for i, squad := range squads { + squadsByID[squad.ID] = squad.Name + squadOptions[i] = types.FieldOption{ + Text: squad.Name, + Value: strconv.Itoa(squad.ID), + } + } + info := connectionLimiterTable.GetInfo().SetFilterFormLayout(form.LayoutFilter) + info.AddField("ID", "id", db.Int). + FieldSortable() + info.AddField("Squads", "squad_ids", db.Varchar). + FieldDisplay(func(model types.FieldModel) interface{} { + values := model.Row["squad_ids"].([]interface{}) + labels := template.HTML("") + labelTpl := label(ctx).SetType("success") + labelValues := make([]string, len(values)) + for i, squadID := range values { + labelValues[i] = squadsByID[int(squadID.(float64))] + } + for key, label := range labelValues { + if key == len(labelValues)-1 { + labels += labelTpl.SetContent(template.HTML(label)).GetContent() + } else { + labels += labelTpl.SetContent(template.HTML(label)).GetContent() + } + } + return labels + }) + info.AddField("Username", "username", db.Varchar). + FieldFilterable(). + FieldSortable() + info.AddField("Outbound", "outbound", db.Varchar). + FieldFilterable(). + FieldSortable() + info.AddField("Strategy", "strategy", db.Varchar). + FieldFilterable(types.FilterType{ + FormType: form.SelectSingle, + Options: types.FieldOptions{ + {Text: "Connection", Value: "connection"}, + }, + }). + FieldSortable() + info.AddField("Connection type", "connection_type", db.Varchar). + FieldFilterable(types.FilterType{ + FormType: form.SelectSingle, + Options: types.FieldOptions{ + {Text: "Mux", Value: "mux"}, + {Text: "HWID", Value: "hwid"}, + {Text: "IP", Value: "ip"}, + }, + }). + FieldSortable() + info.AddField("Lock type", "lock_type", db.Varchar). + FieldFilterable(types.FilterType{ + FormType: form.SelectSingle, + Options: types.FieldOptions{ + {Text: "Manager", Value: "manager"}, + }, + }). + FieldSortable() + info.AddField("Count", "count", db.Int). + FieldSortable() + info.AddField("Created at", "created_at", db.Datetime). + FieldDisplay(func(model types.FieldModel) interface{} { + t, err := time.Parse(time.RFC3339, model.Value) + if err != nil { + return model.Value + } + return t.Format("2006-01-02 15:04:05") + }). + FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). + FieldSortable() + info.AddField("Updated at", "updated_at", db.Datetime). + FieldDisplay(func(model types.FieldModel) interface{} { + t, err := time.Parse(time.RFC3339, model.Value) + if err != nil { + return model.Value + } + return t.Format("2006-01-02 15:04:05") + }). + FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). + FieldSortable() + + info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) { + filters := make(map[string][]string) + listFilters := map[string][]string{ + "offset": {strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)}, + "limit": {param.PageSize}, + } + for k, v := range param.Fields { + if strings.HasPrefix(k, "__") { + continue + } + key := strings.TrimSuffix(k, "__goadmin") + filters[key] = v + listFilters[key] = v + } + if param.SortField != "" { + if param.SortType == "asc" { + listFilters["sort_asc"] = []string{param.SortField} + } else { + listFilters["sort_desc"] = []string{param.SortField} + } + } + items, err := manager.GetConnectionLimiters(listFilters) + if err != nil { + logger.Error(err) + return nil, 0 + } + count, err := manager.GetConnectionLimitersCount(filters) + if err != nil { + logger.Error(err) + return nil, 0 + } + result := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + var data map[string]interface{} + raw, _ := json.Marshal(item) + json.Unmarshal(raw, &data) + result = append(result, data) + } + return result, count + }) + + info.SetDeleteFn(func(ids []string) error { + for _, id := range ids { + i, err := strconv.Atoi(id) + if err != nil { + return err + } + if _, err := manager.DeleteConnectionLimiter(i); err != nil { + return err + } + } + return nil + }) + + info.SetTable("connection_limiters").SetTitle("Connection Limiters").SetDescription("Connection Limiters") + + formList := connectionLimiterTable.GetForm() + formList.AddField("ID", "id", db.Int, form.Default). + FieldNotAllowAdd(). + FieldNotAllowEdit() + formList.AddField("Squads", "squad_ids", db.Varchar, form.Select). + FieldMust(). + FieldOptions(squadOptions). + FieldDisableWhenUpdate() + formList.AddField("Username", "username", db.Varchar, form.Text). + FieldMust(). + FieldDisplayButCanNotEditWhenUpdate() + formList.AddField("Outbound", "outbound", db.Varchar, form.Text). + FieldMust(). + FieldDisplayButCanNotEditWhenUpdate() + formList.AddField("Strategy", "strategy", db.Varchar, form.SelectSingle). + FieldMust(). + FieldOptions(types.FieldOptions{ + {Text: "Connection", Value: "connection"}, + }). + FieldDefault("connection") + formList.AddField("Connection type", "connection_type", db.Varchar, form.SelectSingle). + FieldOptions(types.FieldOptions{ + {Text: "Mux", Value: "mux"}, + {Text: "HWID", Value: "hwid"}, + {Text: "IP", Value: "ip"}, + }) + formList.AddField("Lock type", "lock_type", db.Varchar, form.SelectSingle). + FieldOptions(types.FieldOptions{ + {Text: "Manager", Value: "manager"}, + }) + formList.AddField("Count", "count", db.Int, form.Number). + FieldMust(). + FieldDefault("0") + + formList.SetInsertFn(func(values mForm.Values) error { + squadIDs := make([]int, len(values["squad_ids[]"])) + for i, rawSquadID := range values["squad_ids[]"] { + squadID, err := strconv.Atoi(rawSquadID) + if err != nil { + return err + } + squadIDs[i] = squadID + } + count, err := strconv.ParseUint(values.Get("count"), 10, 32) + if err != nil { + return err + } + _, err = manager.CreateConnectionLimiter(CM.ConnectionLimiterCreate{ + SquadIDs: squadIDs, + Username: values.Get("username"), + Outbound: values.Get("outbound"), + Strategy: values.Get("strategy"), + ConnectionType: values.Get("connection_type"), + LockType: values.Get("lock_type"), + Count: uint32(count), + }) + return err + }) + + formList.SetUpdateFn(func(values mForm.Values) error { + id, err := strconv.Atoi(values.Get("id")) + if err != nil { + return err + } + count, err := strconv.ParseUint(values.Get("count"), 10, 32) + if err != nil { + return err + } + _, err = manager.UpdateConnectionLimiter(id, CM.ConnectionLimiterUpdate{ + Username: values.Get("username"), + Outbound: values.Get("outbound"), + Strategy: values.Get("strategy"), + ConnectionType: values.Get("connection_type"), + LockType: values.Get("lock_type"), + Count: uint32(count), + }) + return err + }) + + formList.SetTable("connection_limiters").SetTitle("Connection Limiters").SetDescription("Connection Limiters") + return connectionLimiterTable + } +} diff --git a/service/admin_panel/tables/node.go b/service/admin_panel/tables/node.go new file mode 100644 index 00000000..76121897 --- /dev/null +++ b/service/admin_panel/tables/node.go @@ -0,0 +1,201 @@ +package tables + +import ( + "encoding/json" + "strconv" + "strings" + "time" + + "github.com/GoAdminGroup/go-admin/context" + "github.com/GoAdminGroup/go-admin/modules/config" + "github.com/GoAdminGroup/go-admin/modules/db" + mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form" + "github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter" + "github.com/GoAdminGroup/go-admin/plugins/admin/modules/table" + "github.com/GoAdminGroup/go-admin/template" + "github.com/GoAdminGroup/go-admin/template/types" + "github.com/GoAdminGroup/go-admin/template/types/form" + "github.com/gofrs/uuid/v5" + + "github.com/sagernet/sing-box/log" + CM "github.com/sagernet/sing-box/service/manager/constant" +) + +func label(ctx *context.Context) types.LabelAttribute { + return template.Get(ctx, config.GetTheme()).Label().SetType("success") +} + +func NodeTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) (nodeTable table.Table) { + return func(ctx *context.Context) (nodeTable table.Table) { + nodeTable = table.NewDefaultTable(ctx, table.Config{ + CanAdd: true, + Editable: true, + Deletable: true, + Exportable: true, + PrimaryKey: table.PrimaryKey{ + Type: db.Varchar, + Name: "uuid", + }, + }) + squads, err := manager.GetSquads(map[string][]string{}) + if err != nil { + return nil + } + squadsByID := make(map[int]string, len(squads)) + squadOptions := make(types.FieldOptions, len(squads)) + for i, squad := range squads { + squadsByID[squad.ID] = squad.Name + squadOptions[i] = types.FieldOption{ + Text: squad.Name, + Value: strconv.Itoa(squad.ID), + } + } + info := nodeTable.GetInfo().SetFilterFormLayout(form.LayoutFilter) + info.AddField("UUID", "uuid", db.Varchar). + FieldFilterable(). + FieldSortable() + info.AddField("Name", "name", db.Varchar). + FieldFilterable(). + FieldSortable() + info.AddField("Squads", "squad_ids", db.Varchar). + FieldDisplay(func(model types.FieldModel) interface{} { + values := model.Row["squad_ids"].([]interface{}) + labels := template.HTML("") + labelTpl := label(ctx).SetType("success") + labelValues := make([]string, len(values)) + for i, squadID := range values { + labelValues[i] = squadsByID[int(squadID.(float64))] + } + for key, label := range labelValues { + if key == len(labelValues)-1 { + labels += labelTpl.SetContent(template.HTML(label)).GetContent() + } else { + labels += labelTpl.SetContent(template.HTML(label)).GetContent() + } + } + return labels + }) + info.AddField("Status", "status", db.Varchar). + FieldDisplay(func(value types.FieldModel) interface{} { + uuid := value.Row["uuid"].(string) + return manager.GetNodeStatus(uuid) + }) + info.AddField("Created at", "created_at", db.Datetime). + FieldDisplay(func(model types.FieldModel) interface{} { + t, err := time.Parse(time.RFC3339, model.Value) + if err != nil { + return model.Value + } + return t.Format("2006-01-02 15:04:05") + }). + FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). + FieldSortable() + info.AddField("Updated at", "updated_at", db.Datetime). + FieldDisplay(func(model types.FieldModel) interface{} { + t, err := time.Parse(time.RFC3339, model.Value) + if err != nil { + return model.Value + } + return t.Format("2006-01-02 15:04:05") + }). + FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). + FieldSortable() + + info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) { + filters := make(map[string][]string, len(param.Fields)) + listFilters := make(map[string][]string, len(param.Fields)+2) + listFilters["offset"] = []string{strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)} + listFilters["limit"] = []string{param.PageSize} + for key, values := range param.Fields { + if key == "__pk" { + key = "uuid" + } else { + if strings.HasPrefix(key, "__") { + continue + } + key = strings.TrimSuffix(key, "__goadmin") + } + filters[key] = values + listFilters[key] = values + } + if param.SortField != "" { + if param.SortType == "asc" { + listFilters["sort_asc"] = []string{param.SortField} + } else { + listFilters["sort_desc"] = []string{param.SortField} + } + } + nodes, err := manager.GetNodes(listFilters) + if err != nil { + logger.Error(err) + return nil, 0 + } + count, err := manager.GetNodesCount(filters) + if err != nil { + logger.Error(err) + return nil, 0 + } + result := make([]map[string]interface{}, 0, len(nodes)) + for _, node := range nodes { + var data map[string]interface{} + rawData, _ := json.Marshal(node) + json.Unmarshal(rawData, &data) + result = append(result, data) + } + return result, count + }) + + info.SetDeleteFn(func(ids []string) error { + for _, uuid := range ids { + if _, err := manager.DeleteNode(uuid); err != nil { + return err + } + } + return nil + }) + + info.SetTable("nodes").SetTitle("Nodes").SetDescription("Nodes") + + defaultUUID, _ := uuid.NewV4() + formList := nodeTable.GetForm() + formList.AddField("UUID", "uuid", db.Varchar, form.Text). + FieldMust(). + FieldNotAllowEdit(). + FieldDefault(defaultUUID.String()) + formList.AddField("Name", "name", db.Varchar, form.Text). + FieldMust() + formList.AddField("Squads", "squad_ids", db.Varchar, form.Select). + FieldMust(). + FieldOptions(squadOptions). + FieldDisableWhenUpdate() + + formList.SetInsertFn(func(values mForm.Values) (err error) { + squadIDs := make([]int, len(values["squad_ids[]"])) + for i, rawSquadID := range values["squad_ids[]"] { + squadID, err := strconv.Atoi(rawSquadID) + if err != nil { + return err + } + squadIDs[i] = squadID + } + _, err = manager.CreateNode(CM.NodeCreate{ + UUID: values.Get("uuid"), + Name: values.Get("name"), + SquadIDs: squadIDs, + }) + return + }) + + formList.SetUpdateFn(func(values mForm.Values) (err error) { + uuid := values.Get("uuid") + _, err = manager.UpdateNode(uuid, CM.NodeUpdate{ + Name: values.Get("name"), + }) + return + }) + + formList.SetTable("nodes").SetTitle("Nodes").SetDescription("Nodes") + + return + } +} diff --git a/service/admin_panel/tables/squad.go b/service/admin_panel/tables/squad.go new file mode 100644 index 00000000..746fe1ea --- /dev/null +++ b/service/admin_panel/tables/squad.go @@ -0,0 +1,164 @@ +package tables + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/GoAdminGroup/go-admin/context" + "github.com/GoAdminGroup/go-admin/modules/db" + mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form" + "github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter" + "github.com/GoAdminGroup/go-admin/plugins/admin/modules/table" + "github.com/GoAdminGroup/go-admin/template/types" + "github.com/GoAdminGroup/go-admin/template/types/form" + "github.com/go-playground/validator/v10" + + "github.com/sagernet/sing-box/log" + CM "github.com/sagernet/sing-box/service/manager/constant" +) + +func SquadTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) (squadTable table.Table) { + return func(ctx *context.Context) (squadTable table.Table) { + squadTable = table.NewDefaultTable(ctx, table.Config{ + CanAdd: true, + Editable: true, + Deletable: true, + Exportable: true, + PrimaryKey: table.PrimaryKey{ + Type: db.Int, + Name: table.DefaultPrimaryKeyName, + }, + }) + + info := squadTable.GetInfo().SetFilterFormLayout(form.LayoutFilter) + info.AddField("ID", "id", db.Int). + FieldSortable() + info.AddField("Name", "name", db.Varchar). + FieldFilterable(). + FieldSortable() + info.AddField("Created At", "created_at", db.Datetime). + FieldDisplay(func(model types.FieldModel) interface{} { + t, err := time.Parse(time.RFC3339, model.Value) + if err != nil { + return model.Value + } + return t.Format("2006-01-02 15:04:05") + }). + FieldSortable(). + FieldFilterable(types.FilterType{FormType: form.DatetimeRange}) + info.AddField("Updated At", "updated_at", db.Datetime). + FieldDisplay(func(model types.FieldModel) interface{} { + t, err := time.Parse(time.RFC3339, model.Value) + if err != nil { + return model.Value + } + return t.Format("2006-01-02 15:04:05") + }). + FieldSortable(). + FieldFilterable(types.FilterType{FormType: form.DatetimeRange}) + + info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) { + filters := make(map[string][]string, len(param.Fields)) + listFilters := make(map[string][]string, len(param.Fields)+2) + listFilters["offset"] = []string{strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)} + listFilters["limit"] = []string{param.PageSize} + for key, values := range param.Fields { + if key == "__pk" { + key = "pk" + } else if strings.HasPrefix(key, "__") { + continue + } else { + key = strings.TrimSuffix(key, "__goadmin") + } + filters[key] = values + listFilters[key] = values + } + if param.SortField != "" { + if param.SortType == "asc" { + listFilters["sort_asc"] = []string{param.SortField} + } else { + listFilters["sort_desc"] = []string{param.SortField} + } + } + squads, err := manager.GetSquads(listFilters) + if err != nil { + logger.Error(err) + return nil, 0 + } + count, err := manager.GetSquadsCount(filters) + if err != nil { + logger.Error(err) + return nil, 0 + } + result := make([]map[string]interface{}, 0, len(squads)) + for _, squad := range squads { + var data map[string]interface{} + rawData, _ := json.Marshal(squad) + json.Unmarshal(rawData, &data) + result = append(result, data) + } + return result, count + }) + + info.SetDeleteFn(func(ids []string) error { + for _, id := range ids { + intID, err := strconv.Atoi(id) + if err != nil { + return err + } + if _, err := manager.DeleteSquad(intID); err != nil { + return err + } + } + return nil + }) + + info.SetTable("squads").SetTitle("Squads").SetDescription("Squads") + + formList := squadTable.GetForm() + formList.AddField("ID", "id", db.Int, form.Default). + FieldNotAllowAdd(). + FieldNotAllowEdit() + formList.AddField("Name", "name", db.Varchar, form.Text). + FieldMust() + + formList.SetInsertFn(func(values mForm.Values) (err error) { + _, err = manager.CreateSquad(CM.SquadCreate{ + Name: values.Get("name"), + }) + if err != nil { + if ve, ok := err.(validator.ValidationErrors); ok { + var errors []string + for _, e := range ve { + switch e.Tag() { + case "required": + errors = append(errors, e.StructField()+": required field missing") + default: + errors = append(errors, e.StructField()+": invalid request") + } + } + err = fmt.Errorf("%s", strings.Join(errors, "
")) + } + } + return + }) + + formList.SetUpdateFn(func(values mForm.Values) (err error) { + id, err := strconv.Atoi(values.Get("id")) + if err != nil { + return err + } + _, err = manager.UpdateSquad(id, CM.SquadUpdate{ + Name: values.Get("name"), + }) + return + }) + + formList.SetTable("squads").SetTitle("Squads").SetDescription("Squads") + + return + } +} diff --git a/service/admin_panel/tables/user.go b/service/admin_panel/tables/user.go new file mode 100644 index 00000000..23087c4f --- /dev/null +++ b/service/admin_panel/tables/user.go @@ -0,0 +1,282 @@ +package tables + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/GoAdminGroup/go-admin/context" + "github.com/GoAdminGroup/go-admin/modules/db" + mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form" + "github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter" + "github.com/GoAdminGroup/go-admin/plugins/admin/modules/table" + "github.com/GoAdminGroup/go-admin/template" + "github.com/GoAdminGroup/go-admin/template/types" + "github.com/GoAdminGroup/go-admin/template/types/form" + "github.com/go-playground/validator/v10" + + "github.com/sagernet/sing-box/log" + CM "github.com/sagernet/sing-box/service/manager/constant" +) + +func UserTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) (userTable table.Table) { + return func(ctx *context.Context) (userTable table.Table) { + userTable = table.NewDefaultTable(ctx, table.Config{ + CanAdd: true, + Editable: true, + Deletable: true, + Exportable: true, + PrimaryKey: table.PrimaryKey{ + Type: db.Int, + Name: table.DefaultPrimaryKeyName, + }, + }) + squads, err := manager.GetSquads(map[string][]string{}) + if err != nil { + return nil + } + squadsByID := make(map[int]string, len(squads)) + squadOptions := make(types.FieldOptions, len(squads)) + for i, squad := range squads { + squadsByID[squad.ID] = squad.Name + squadOptions[i] = types.FieldOption{ + Text: squad.Name, + Value: strconv.Itoa(squad.ID), + } + } + info := userTable.GetInfo().SetFilterFormLayout(form.LayoutFilter) + info.AddField("ID", "id", db.Int). + FieldSortable() + info.AddField("Squads", "squad_ids", db.Varchar). + FieldDisplay(func(model types.FieldModel) interface{} { + values := model.Row["squad_ids"].([]interface{}) + labels := template.HTML("") + labelTpl := label(ctx).SetType("success") + labelValues := make([]string, len(values)) + for i, squadID := range values { + labelValues[i] = squadsByID[int(squadID.(float64))] + } + for key, label := range labelValues { + if key == len(labelValues)-1 { + labels += labelTpl.SetContent(template.HTML(label)).GetContent() + } else { + labels += labelTpl.SetContent(template.HTML(label)).GetContent() + } + } + return labels + }) + info.AddField("Username", "username", db.Varchar). + FieldFilterable(). + FieldSortable() + info.AddField("Type", "type", db.Varchar). + FieldFilterable( + types.FilterType{ + FormType: form.SelectSingle, + Options: types.FieldOptions{ + {Text: "Hysteria", Value: "hysteria"}, + {Text: "Hysteria2", Value: "hysteria2"}, + {Text: "Trojan", Value: "trojan"}, + {Text: "TUIC", Value: "tuic"}, + {Text: "VLESS", Value: "vless"}, + {Text: "VMess", Value: "vmess"}, + }, + }, + ). + FieldSortable() + info.AddField("Inbound", "inbound", db.Varchar).FieldFilterable(). + FieldSortable() + info.AddField("Created at", "created_at", db.Datetime). + FieldDisplay(func(model types.FieldModel) interface{} { + t, err := time.Parse(time.RFC3339, model.Value) + if err != nil { + return model.Value + } + return t.Format("2006-01-02 15:04:05") + }). + FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). + FieldSortable() + info.AddField("Updated at", "updated_at", db.Datetime). + FieldDisplay(func(model types.FieldModel) interface{} { + t, err := time.Parse(time.RFC3339, model.Value) + if err != nil { + return model.Value + } + return t.Format("2006-01-02 15:04:05") + }). + FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). + FieldSortable() + + info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) { + filters := make(map[string][]string, len(param.Fields)) + listFilters := make(map[string][]string, len(param.Fields)+2) + listFilters["offset"] = []string{strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)} + listFilters["limit"] = []string{param.PageSize} + for key, values := range param.Fields { + if key == "__pk" { + key = "pk" + } else { + if strings.HasPrefix(key, "__") { + continue + } + key = strings.TrimSuffix(key, "__goadmin") + } + filters[key] = values + listFilters[key] = values + } + if param.SortField != "" { + if param.SortType == "asc" { + listFilters["sort_asc"] = []string{param.SortField} + } else { + listFilters["sort_desc"] = []string{param.SortField} + } + } + users, err := manager.GetUsers(listFilters) + if err != nil { + logger.Error(err) + return nil, 0 + } + count, err := manager.GetUsersCount(filters) + if err != nil { + logger.Error(err) + return nil, 0 + } + result := make([]map[string]interface{}, 0, len(users)) + for _, user := range users { + var data map[string]interface{} + rawData, _ := json.Marshal(user) + json.Unmarshal(rawData, &data) + result = append(result, data) + } + return result, count + }) + info.SetDeleteFn(func(ids []string) error { + for _, id := range ids { + value, err := strconv.Atoi(id) + if err != nil { + return err + } + if _, err := manager.DeleteUser(value); err != nil { + return err + } + } + return nil + }) + + info.SetTable("users").SetTitle("Users").SetDescription("Users") + + formList := userTable.GetForm() + formList.AddField("ID", "id", db.Int, form.Default). + FieldNotAllowEdit(). + FieldNotAllowAdd() + formList.AddField("Squads", "squad_ids", db.Varchar, form.Select). + FieldMust(). + FieldOptions(squadOptions). + FieldDisableWhenUpdate() + formList.AddField("Username", "username", db.Varchar, form.Text). + FieldMust(). + FieldDisplayButCanNotEditWhenUpdate() + formList.AddField("Type", "type", db.Varchar, form.SelectSingle). + FieldMust(). + FieldDisplayButCanNotEditWhenUpdate(). + FieldOptions(types.FieldOptions{ + {Text: "Hysteria", Value: "hysteria"}, + {Text: "Hysteria2", Value: "hysteria2"}, + {Text: "Trojan", Value: "trojan"}, + {Text: "TUIC", Value: "tuic"}, + {Text: "VLESS", Value: "vless"}, + {Text: "VMess", Value: "vmess"}, + }). + FieldOnChooseOptionsHide([]string{""}, "inbound"). + FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "shadowsocks", "trojan", "tuic"}, "uuid"). + FieldOnChooseOptionsHide([]string{"", "vless", "vmess"}, "password"). + FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "shadowsocks", "trojan", "tuic", "vmess"}, "flow"). + FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "shadowsocks", "trojan", "tuic", "vless"}, "alter_id") + formList.AddField("Inbound", "inbound", db.Varchar, form.Text). + FieldMust(). + FieldDisplayButCanNotEditWhenUpdate(). + FieldOptionInitFn(func(val types.FieldModel) types.FieldOptions { + return types.FieldOptions{ + {Value: val.Value, Text: val.Value, Selected: true}, + } + }) + formList.AddField("UUID", "uuid", db.Varchar, form.Text) + formList.AddField("Password", "password", db.Varchar, form.Text) + formList.AddField("Flow", "flow", db.Varchar, form.SelectSingle). + FieldOptions(types.FieldOptions{ + {Text: "xtls-rprx-vision", Value: "xtls-rprx-vision"}, + }) + formList.AddField("Alter ID", "alter_id", db.Varchar, form.Number). + FieldDefault("0") + + formList.SetInsertFn(func(values mForm.Values) (err error) { + squadIDs := make([]int, len(values["squad_ids[]"])) + for i, rawSquadID := range values["squad_ids[]"] { + squadID, err := strconv.Atoi(rawSquadID) + if err != nil { + return err + } + squadIDs[i] = squadID + } + var alterId int + if value := values.Get("alter_id"); value != "" { + alterId, err = strconv.Atoi(value) + if err != nil { + return err + } + } + _, err = manager.CreateUser(CM.UserCreate{ + SquadIDs: squadIDs, + Username: values.Get("username"), + Type: values.Get("type"), + Inbound: values.Get("inbound"), + UUID: values.Get("uuid"), + Password: values.Get("password"), + Flow: values.Get("flow"), + AlterID: alterId, + }) + if err != nil { + if ve, ok := err.(validator.ValidationErrors); ok { + var errors []string + for _, e := range ve { + switch e.Tag() { + case "required": + errors = append(errors, e.StructField()+": required field missing") + case "uuid4": + errors = append(errors, e.StructField()+": invalid UUID") + default: + errors = append(errors, e.StructField()+": invalid request") + } + } + err = fmt.Errorf("%s", strings.Join(errors, "
")) + } + } + return + }) + formList.SetUpdateFn(func(values mForm.Values) (err error) { + id, err := strconv.Atoi(values.Get("id")) + if err != nil { + return err + } + var alterId int + if value := values.Get("alter_id"); value != "" { + alterId, err = strconv.Atoi(value) + if err != nil { + return err + } + } + _, err = manager.UpdateUser(id, CM.UserUpdate{ + UUID: values.Get("uuid"), + Password: values.Get("password"), + Flow: values.Get("flow"), + AlterID: alterId, + }) + return + }) + + formList.SetTable("users").SetTitle("Users").SetDescription("Users") + + return + } +} diff --git a/service/manager/constant/dto.go b/service/manager/constant/dto.go new file mode 100644 index 00000000..0aae5914 --- /dev/null +++ b/service/manager/constant/dto.go @@ -0,0 +1,164 @@ +package constant + +import "time" + +type Squad struct { + ID int `json:"id" validate:"required"` + Name string `json:"name" validate:"required"` + CreatedAt time.Time `json:"created_at" validate:"required"` + UpdatedAt time.Time `json:"updated_at" validate:"required"` +} + +type SquadCreate struct { + Name string `json:"name" validate:"required"` +} + +type SquadUpdate struct { + Name string `json:"name" validate:"required"` +} + +type Node struct { + UUID string `json:"uuid" validate:"required,uuid4"` + Name string `json:"name" validate:"required"` + SquadIDs []int `json:"squad_ids" validate:"required"` + CreatedAt time.Time `json:"created_at" validate:"required"` + UpdatedAt time.Time `json:"updated_at" validate:"required"` +} + +type NodeCreate struct { + UUID string `json:"uuid" validate:"required,uuid4"` + Name string `json:"name" validate:"required"` + SquadIDs []int `json:"squad_ids" validate:"required"` +} + +type NodeUpdate struct { + Name string `json:"name" validate:"required"` +} + +type BaseNode struct { + UUID string `json:"uuid" validate:"required,uuid4"` + Name string `json:"name" validate:"required"` +} + +type User struct { + ID int `json:"id" validate:"required"` + SquadIDs []int `json:"squad_ids" validate:"required"` + Username string `json:"username" validate:"required"` + Type string `json:"type" validate:"required"` + Inbound string `json:"inbound" validate:"required"` + UUID string `json:"uuid" validate:"required"` + Password string `json:"password" validate:"required"` + Flow string `json:"flow" validate:"required"` + AlterID int `json:"alter_id" validate:"required"` + CreatedAt time.Time `json:"created_at" validate:"required"` + UpdatedAt time.Time `json:"updated_at" validate:"required"` +} + +type UserCreate struct { + SquadIDs []int `json:"squad_ids" validate:"required"` + Username string `json:"username" validate:"required"` + Type string `json:"type" validate:"required,oneof=hysteria hysteria2 trojan tuic vless vmess"` + Inbound string `json:"inbound" validate:"required"` + UUID string `json:"uuid" validate:"omitempty,uuid4"` + Password string `json:"password" validate:"omitempty"` + Flow string `json:"flow" validate:"omitempty"` + AlterID int `json:"alter_id" validate:"omitempty"` +} + +type UserUpdate struct { + UUID string `json:"uuid" validate:"omitempty,uuid4"` + Password string `json:"password" validate:"omitempty"` + Flow string `json:"flow" validate:"omitempty"` + AlterID int `json:"alter_id" validate:"omitempty"` +} + +type BaseUser struct { + UUID string `json:"uuid" validate:"omitempty,uuid4"` + Password string `json:"password" validate:"omitempty"` + Flow string `json:"flow" validate:"omitempty"` + AlterID int `json:"alter_id" validate:"omitempty"` +} + +type ConnectionLimiter struct { + ID int `json:"id" validate:"required"` + SquadIDs []int `json:"squad_ids" validate:"required"` + Username string `json:"username" validate:"required"` + Outbound string `json:"outbound" validate:"required"` + Strategy string `json:"strategy" validate:"required,oneof=connection"` + ConnectionType string `json:"connection_type" validate:"omitempty,oneof=hwid mux ip"` + LockType string `json:"lock_type" validate:"omitempty,oneof=manager"` + Count uint32 `json:"count" validate:"required"` + CreatedAt time.Time `json:"created_at" validate:"required"` + UpdatedAt time.Time `json:"updated_at" validate:"required"` +} + +type ConnectionLimiterCreate struct { + SquadIDs []int `json:"squad_ids" validate:"required"` + Username string `json:"username" validate:"required"` + Outbound string `json:"outbound" validate:"required"` + Strategy string `json:"strategy" validate:"required,oneof=connection"` + ConnectionType string `json:"type" validate:"omitempty,oneof=hwid mux ip"` + LockType string `json:"lock_type" validate:"omitempty,oneof=manager"` + Count uint32 `json:"count" validate:"required"` +} + +type ConnectionLimiterUpdate struct { + Username string `json:"username" validate:"required"` + Outbound string `json:"outbound" validate:"required"` + Strategy string `json:"strategy" validate:"required,oneof=connection"` + ConnectionType string `json:"type" validate:"omitempty,oneof=hwid mux ip"` + LockType string `json:"lock_type" validate:"omitempty,oneof=manager"` + Count uint32 `json:"count" validate:"required"` +} + +type BaseConnectionLimiter struct { + Username string `json:"username" validate:"required"` + Outbound string `json:"outbound" validate:"required"` + Strategy string `json:"strategy" validate:"required,oneof=connection"` + ConnectionType string `json:"type" validate:"omitempty,oneof=hwid mux ip"` + LockType string `json:"lock_type" validate:"omitempty,oneof=manager"` + Count uint32 `json:"count" validate:"required"` +} + +type BandwidthLimiter struct { + ID int `json:"id" validate:"required"` + SquadIDs []int `json:"squad_ids" validate:"required"` + Username string `json:"username" validate:"required"` + Outbound string `json:"outbound" validate:"required"` + Strategy string `json:"strategy" validate:"required"` + Mode string `json:"mode" validate:"required"` + ConnectionType string `json:"connection_type" validate:"omitempty"` + Speed string `json:"speed" validate:"required"` + RawSpeed uint64 `json:"raw_speed" validate:"required"` + CreatedAt time.Time `json:"created_at" validate:"required"` + UpdatedAt time.Time `json:"updated_at" validate:"required"` +} + +type BandwidthLimiterCreate struct { + SquadIDs []int `json:"squad_ids" validate:"required"` + Username string `json:"username" validate:"required"` + Outbound string `json:"outbound" validate:"required"` + Strategy string `json:"strategy" validate:"required,oneof=global connection"` + Mode string `json:"mode" validate:"required"` + ConnectionType string `json:"connection_type" validate:"omitempty"` + Speed string `json:"speed" validate:"required"` +} + +type BandwidthLimiterUpdate struct { + Username string `json:"username" validate:"required"` + Outbound string `json:"outbound" validate:"required"` + Strategy string `json:"strategy" validate:"required,oneof=global connection"` + Mode string `json:"mode" validate:"required"` + ConnectionType string `json:"connection_type" validate:"omitempty"` + Speed string `json:"speed" validate:"required"` +} + +type BaseBandwidthLimiter struct { + Username string `json:"username" validate:"required"` + Outbound string `json:"outbound" validate:"required"` + Strategy string `json:"strategy" validate:"required,oneof=global connection"` + Mode string `json:"mode" validate:"required"` + ConnectionType string `json:"connection_type" validate:"omitempty"` + Speed string `json:"speed" validate:"required"` + RawSpeed uint64 `json:"raw_speed" validate:"required"` +} diff --git a/service/manager/constant/error.go b/service/manager/constant/error.go new file mode 100644 index 00000000..5b332016 --- /dev/null +++ b/service/manager/constant/error.go @@ -0,0 +1,5 @@ +package constant + +import E "github.com/sagernet/sing/common/exceptions" + +var ErrNotFound = E.New("not found") diff --git a/service/manager/constant/manager.go b/service/manager/constant/manager.go new file mode 100644 index 00000000..9cfc7582 --- /dev/null +++ b/service/manager/constant/manager.go @@ -0,0 +1,48 @@ +package constant + +type NodeManager interface { + AddNode(id string, node ConnectedNode) error + AcquireLock(limiterId int, id string) (string, error) + RefreshLock(limiterId int, id string, handleId string) error + ReleaseLock(limiterId int, id string, handleId string) error +} + +type Manager interface { + NodeManager + + CreateSquad(user SquadCreate) (Squad, error) + GetSquads(filters map[string][]string) ([]Squad, error) + GetSquadsCount(filters map[string][]string) (int, error) + GetSquad(id int) (Squad, error) + UpdateSquad(id int, user SquadUpdate) (Squad, error) + DeleteSquad(id int) (Squad, error) + + CreateNode(node NodeCreate) (Node, error) + GetNodes(filters map[string][]string) ([]Node, error) + GetNodesCount(filters map[string][]string) (int, error) + GetNode(uuid string) (Node, error) + GetNodeStatus(uuid string) string + UpdateNode(uuid string, node NodeUpdate) (Node, error) + DeleteNode(uuid string) (Node, error) + + CreateUser(user UserCreate) (User, error) + GetUsers(filters map[string][]string) ([]User, error) + GetUsersCount(filters map[string][]string) (int, error) + GetUser(id int) (User, error) + UpdateUser(id int, user UserUpdate) (User, error) + DeleteUser(id int) (User, error) + + CreateBandwidthLimiter(limiter BandwidthLimiterCreate) (BandwidthLimiter, error) + GetBandwidthLimiters(filters map[string][]string) ([]BandwidthLimiter, error) + GetBandwidthLimitersCount(filters map[string][]string) (int, error) + GetBandwidthLimiter(id int) (BandwidthLimiter, error) + UpdateBandwidthLimiter(id int, limiter BandwidthLimiterUpdate) (BandwidthLimiter, error) + DeleteBandwidthLimiter(id int) (BandwidthLimiter, error) + + CreateConnectionLimiter(limiter ConnectionLimiterCreate) (ConnectionLimiter, error) + GetConnectionLimiters(filters map[string][]string) ([]ConnectionLimiter, error) + GetConnectionLimitersCount(filters map[string][]string) (int, error) + GetConnectionLimiter(id int) (ConnectionLimiter, error) + UpdateConnectionLimiter(id int, limiter ConnectionLimiterUpdate) (ConnectionLimiter, error) + DeleteConnectionLimiter(id int) (ConnectionLimiter, error) +} diff --git a/service/manager/constant/node.go b/service/manager/constant/node.go new file mode 100644 index 00000000..b302cf02 --- /dev/null +++ b/service/manager/constant/node.go @@ -0,0 +1,20 @@ +package constant + +type ConnectedNode interface { + UpdateUser(user User) + UpdateUsers(users []User) + DeleteUser(user User) + + UpdateConnectionLimiter(limiter ConnectionLimiter) + UpdateConnectionLimiters(limiter []ConnectionLimiter) + DeleteConnectionLimiter(limiter ConnectionLimiter) + + UpdateBandwidthLimiter(limiter BandwidthLimiter) + UpdateBandwidthLimiters(limiter []BandwidthLimiter) + DeleteBandwidthLimiter(limiter BandwidthLimiter) + + IsLocal() bool + IsOnline() bool + + Close() error +} diff --git a/service/manager/constant/repository.go b/service/manager/constant/repository.go new file mode 100644 index 00000000..57dc72cb --- /dev/null +++ b/service/manager/constant/repository.go @@ -0,0 +1,38 @@ +package constant + +type Repository interface { + CreateSquad(user SquadCreate) (Squad, error) + GetSquads(filters map[string][]string) ([]Squad, error) + GetSquadsCount(filters map[string][]string) (int, error) + GetSquad(id int) (Squad, error) + UpdateSquad(id int, user SquadUpdate) (Squad, error) + DeleteSquad(id int) (Squad, error) + + CreateNode(node NodeCreate) (Node, error) + GetNodes(filters map[string][]string) ([]Node, error) + GetNodesCount(filters map[string][]string) (int, error) + GetNode(uuid string) (Node, error) + UpdateNode(uuid string, node NodeUpdate) (Node, error) + DeleteNode(uuid string) (Node, error) + + CreateUser(user UserCreate) (User, error) + GetUsers(filters map[string][]string) ([]User, error) + GetUsersCount(filters map[string][]string) (int, error) + GetUser(id int) (User, error) + UpdateUser(id int, user UserUpdate) (User, error) + DeleteUser(id int) (User, error) + + CreateConnectionLimiter(limiter ConnectionLimiterCreate) (ConnectionLimiter, error) + GetConnectionLimiters(filters map[string][]string) ([]ConnectionLimiter, error) + GetConnectionLimitersCount(filters map[string][]string) (int, error) + GetConnectionLimiter(id int) (ConnectionLimiter, error) + UpdateConnectionLimiter(id int, limiter ConnectionLimiterUpdate) (ConnectionLimiter, error) + DeleteConnectionLimiter(id int) (ConnectionLimiter, error) + + CreateBandwidthLimiter(limiter BandwidthLimiterCreate) (BandwidthLimiter, error) + GetBandwidthLimiters(filters map[string][]string) ([]BandwidthLimiter, error) + GetBandwidthLimitersCount(filters map[string][]string) (int, error) + GetBandwidthLimiter(id int) (BandwidthLimiter, error) + UpdateBandwidthLimiter(id int, limiter BandwidthLimiterUpdate) (BandwidthLimiter, error) + DeleteBandwidthLimiter(id int) (BandwidthLimiter, error) +} diff --git a/service/manager/repository/postgresql/filter.go b/service/manager/repository/postgresql/filter.go new file mode 100644 index 00000000..7065f810 --- /dev/null +++ b/service/manager/repository/postgresql/filter.go @@ -0,0 +1,155 @@ +package postgresql + +import ( + "encoding/json" + "strconv" + + "github.com/huandu/go-sqlbuilder" + "github.com/sagernet/sing/common/byteformats" +) + +type Filter func(sb *sqlbuilder.SelectBuilder, value []string) error + +func EqualFilter(field string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + sb.Where(sb.Equal(field, value[0])) + return nil + } +} + +func EqualOrNullFilter(field string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + sb.Where(sb.Or(sb.Equal(field, value[0]), sb.IsNull(field))) + return nil + } +} + +func GreaterThanFilter(field string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + sb.Where(sb.GreaterThan(field, value[0])) + return nil + } +} + +func LessThanFilter(field string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + sb.Where(sb.LessThan(field, value[0])) + return nil + } +} + +func GreaterEqualThanFilter(field string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + sb.Where(sb.GreaterEqualThan(field, value[0])) + return nil + } +} + +func LessEqualThanFilter(field string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + sb.Where(sb.LessEqualThan(field, value[0])) + return nil + } +} + +func SpeedGreaterEqualThanFilter(field string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + bytesSpeed, err := json.Marshal(value[0]) + if err != nil { + return err + } + speed := &byteformats.NetworkBytesCompat{} + err = speed.UnmarshalJSON(bytesSpeed) + if err != nil { + return err + } + sb.Where(sb.GreaterEqualThan(field, speed.Value())) + return nil + } +} + +func SpeedLessEqualThanFilter(field string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + bytesSpeed, err := json.Marshal(value[0]) + if err != nil { + return err + } + speed := &byteformats.NetworkBytesCompat{} + err = speed.UnmarshalJSON(bytesSpeed) + if err != nil { + return err + } + sb.Where(sb.LessEqualThan(field, speed.Value())) + return nil + } +} + +func ExistsAndWhereInFilter(subquery *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.Where(subquery.In(field, values...)) + sb.Where(sb.Exists(subquery)) + return nil + } +} + +func SortAscFilter() 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]) + } + return nil + } +} + +func ReplacedSortDescFilter(replace map[string]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]) + } + return nil + } +} + +func LimitFilter() Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + limit, err := strconv.Atoi(value[0]) + if err != nil { + return err + } + sb.Limit(limit) + return nil + } +} + +func OffsetFilter() Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + offset, err := strconv.Atoi(value[0]) + if err != nil { + return err + } + sb.Offset(offset) + return nil + } +} diff --git a/service/manager/repository/postgresql/migration.go b/service/manager/repository/postgresql/migration.go new file mode 100644 index 00000000..4a426e38 --- /dev/null +++ b/service/manager/repository/postgresql/migration.go @@ -0,0 +1,132 @@ +package postgresql + +import ( + "database/sql" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/postgres" + "github.com/sagernet/sing-box/common/migrate/source" +) + +var migrations = map[string]string{ + "1_initialize_schema.up.sql": ` + CREATE TABLE squads ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + + CREATE TABLE nodes ( + uuid VARCHAR(36) PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + + CREATE TABLE node_to_squad ( + node_uuid VARCHAR(36) NOT NULL, + squad_id INTEGER NOT NULL, + PRIMARY KEY (node_uuid, squad_id), + FOREIGN KEY (node_uuid) REFERENCES nodes(uuid) ON DELETE CASCADE, + FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE RESTRICT + ); + + CREATE TABLE users ( + id SERIAL PRIMARY KEY, + node_uuid VARCHAR(36), + username TEXT NOT NULL, + type TEXT NOT NULL, + inbound TEXT NOT NULL, + uuid TEXT NOT NULL, + password TEXT NOT NULL, + flow TEXT NOT NULL, + alter_id INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + UNIQUE (username, inbound) + ); + + CREATE TABLE user_to_squad ( + user_id INTEGER NOT NULL, + squad_id INTEGER NOT NULL, + PRIMARY KEY (user_id, squad_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE RESTRICT + ); + + CREATE TABLE connection_limiters ( + id SERIAL PRIMARY KEY, + username TEXT NOT NULL, + outbound TEXT NOT NULL, + strategy TEXT NOT NULL, + connection_type TEXT NOT NULL, + lock_type TEXT NOT NULL, + count INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + UNIQUE (username, outbound) + ); + + CREATE TABLE connection_limiter_to_squad ( + connection_limiter_id INTEGER NOT NULL, + squad_id INTEGER NOT NULL, + PRIMARY KEY (connection_limiter_id, squad_id), + FOREIGN KEY (connection_limiter_id) REFERENCES connection_limiters(id) ON DELETE CASCADE, + FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE RESTRICT + ); + + CREATE TABLE bandwidth_limiters ( + id SERIAL PRIMARY KEY, + username TEXT NOT NULL, + outbound TEXT NOT NULL, + strategy TEXT NOT NULL, + mode TEXT NOT NULL, + connection_type TEXT NOT NULL, + speed TEXT NOT NULL, + raw_speed BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + UNIQUE (username, outbound) + ); + + CREATE TABLE bandwidth_limiter_to_squad ( + bandwidth_limiter_id INTEGER NOT NULL, + squad_id INTEGER NOT NULL, + PRIMARY KEY (bandwidth_limiter_id, squad_id), + FOREIGN KEY (bandwidth_limiter_id) REFERENCES bandwidth_limiters(id) ON DELETE CASCADE, + FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE RESTRICT + ); + `, + "1_initialize_schema.down.sql": ` + DROP TABLE IF EXISTS squas; + DROP TABLE IF EXISTS nodes; + DROP TABLE IF EXISTS users; + DROP TABLE IF EXISTS bandwidth_limiters; + DROP TABLE IF EXISTS connection_limiters; + `, +} + +func Migrate(db *sql.DB) error { + driver, err := postgres.WithInstance(db, &postgres.Config{}) + if err != nil { + return err + } + + sourceDriver := source.NewRawDriver(migrations) + if err := sourceDriver.Init(); err != nil { + return err + } + + m, err := migrate.NewWithInstance( + "raw", + sourceDriver, + "postgres", + driver, + ) + if err != nil { + return err + } + + return m.Up() +} diff --git a/service/manager/repository/postgresql/repository.go b/service/manager/repository/postgresql/repository.go new file mode 100644 index 00000000..29a1a526 --- /dev/null +++ b/service/manager/repository/postgresql/repository.go @@ -0,0 +1,1347 @@ +package postgresql + +import ( + "context" + "database/sql" + "encoding/json" + "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/service/manager/constant" + "github.com/sagernet/sing/common/byteformats" +) + +var ( + squadFilters, nodeFilters, userFilters, bandwidthLimiterFilters, connectionLimiterFilters map[string]Filter +) + +type PostgreSQLRepository struct { + db *pgxpool.Pool + ctx context.Context +} + +func NewPostgreSQLRepository(ctx context.Context, dsn string) (*PostgreSQLRepository, error) { + db, err := sql.Open("postgres", dsn) + if err != nil { + return nil, err + } + defer db.Close() + if err := Migrate(db); err != nil && err != migrate.ErrNoChange { + return nil, err + } + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return nil, err + } + return &PostgreSQLRepository{db: pool, ctx: ctx}, nil +} + +func (r *PostgreSQLRepository) CreateSquad(squad constant.SquadCreate) (constant.Squad, error) { + var s constant.Squad + now := time.Now() + err := r.db.QueryRow(r.ctx, ` + INSERT INTO squads + ( + name, + created_at, + updated_at + ) + VALUES ($1, $2, $3) + RETURNING + id, + name, + created_at, + updated_at + `, + squad.Name, + now, + now, + ).Scan( + &s.ID, + &s.Name, + &s.CreatedAt, + &s.UpdatedAt, + ) + return s, err +} + +func (r *PostgreSQLRepository) GetSquad(id int) (constant.Squad, error) { + var s constant.Squad + err := r.db.QueryRow(r.ctx, ` + SELECT + id, + name, + created_at, + updated_at + FROM squads + WHERE id=$1 + `, id).Scan( + &s.ID, + &s.Name, + &s.CreatedAt, + &s.UpdatedAt, + ) + return s, err +} + +func (r *PostgreSQLRepository) GetSquads(filters map[string][]string) ([]constant.Squad, error) { + sb := sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select( + "id", + "name", + "created_at", + "updated_at", + ). + From("squads") + for k, v := range filters { + if f, ok := squadFilters[k]; ok { + if err := f(sb, v); err != nil { + return nil, err + } + } + } + sql, args := sb.Build() + rows, err := r.db.Query(r.ctx, sql, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var result []constant.Squad + for rows.Next() { + var squad constant.Squad + if err := rows.Scan( + &squad.ID, + &squad.Name, + &squad.CreatedAt, + &squad.UpdatedAt, + ); err != nil { + return nil, err + } + result = append(result, squad) + } + return result, rows.Err() +} + +func (r *PostgreSQLRepository) GetSquadsCount(filters map[string][]string) (int, error) { + sb := sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select("COUNT(*)"). + From("squads") + for k, v := range filters { + if f, ok := squadFilters[k]; ok { + if err := f(sb, v); err != nil { + return 0, err + } + } + } + sql, args := sb.Build() + var count int + err := r.db.QueryRow(r.ctx, sql, args...).Scan(&count) + return count, err +} + +func (r *PostgreSQLRepository) UpdateSquad(id int, squad constant.SquadUpdate) (constant.Squad, error) { + var s constant.Squad + err := r.db.QueryRow(r.ctx, ` + UPDATE squads + SET + name=$1, + updated_at=$2 + WHERE id=$3 + RETURNING + id, + name, + created_at, + updated_at + `, + squad.Name, + time.Now(), + id, + ).Scan( + &s.ID, + &s.Name, + &s.CreatedAt, + &s.UpdatedAt, + ) + return s, err +} + +func (r *PostgreSQLRepository) DeleteSquad(id int) (constant.Squad, error) { + var s constant.Squad + err := r.db.QueryRow(r.ctx, ` + DELETE FROM squads + WHERE id=$1 + RETURNING + id, + name, + created_at, + updated_at + `, id).Scan( + &s.ID, + &s.Name, + &s.CreatedAt, + &s.UpdatedAt, + ) + return s, err +} + +func (r *PostgreSQLRepository) CreateNode(node constant.NodeCreate) (constant.Node, error) { + var n constant.Node + tx, err := r.db.Begin(r.ctx) + if err != nil { + return n, err + } + defer tx.Rollback(r.ctx) + now := time.Now() + err = tx.QueryRow(r.ctx, ` + INSERT INTO nodes ( + uuid, + name, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4) + RETURNING + uuid, + name, + created_at, + updated_at + `, + node.UUID, + node.Name, + now, + now, + ).Scan( + &n.UUID, + &n.Name, + &n.CreatedAt, + &n.UpdatedAt, + ) + if err != nil { + return n, err + } + rows := make([][]any, len(node.SquadIDs)) + for i, squadID := range node.SquadIDs { + rows[i] = []any{node.UUID, squadID} + } + _, err = tx.CopyFrom( + r.ctx, + pgx.Identifier{"node_to_squad"}, + []string{"node_uuid", "squad_id"}, + pgx.CopyFromRows(rows), + ) + if err != nil { + return n, err + } + err = tx.Commit(r.ctx) + if err != nil { + return n, err + } + return n, err +} + +func (r *PostgreSQLRepository) GetNodes(filters map[string][]string) ([]constant.Node, error) { + sb := sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select( + "uuid", + "name", + `ARRAY( + SELECT squad_id + FROM node_to_squad + WHERE node_to_squad.node_uuid = nodes.uuid + ) as squad_ids`, + "created_at", + "updated_at", + ). + From("nodes") + for key, value := range filters { + if filter, ok := nodeFilters[key]; ok { + if err := filter(sb, value); err != nil { + return nil, err + } + } + } + sql, args := sb.Build() + rows, err := r.db.Query(r.ctx, sql, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var result []constant.Node + for rows.Next() { + var n constant.Node + if err := rows.Scan( + &n.UUID, + &n.Name, + &n.SquadIDs, + &n.CreatedAt, + &n.UpdatedAt, + ); err != nil { + return nil, err + } + result = append(result, n) + } + return result, rows.Err() +} + +func (r *PostgreSQLRepository) GetNodesCount(filters map[string][]string) (int, error) { + sb := sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select("COUNT(*)"). + From("nodes") + for key, value := range filters { + if filter, ok := nodeFilters[key]; ok { + if err := filter(sb, value); err != nil { + return 0, err + } + } + } + sql, args := sb.Build() + var count int + err := r.db.QueryRow(r.ctx, sql, args...).Scan(&count) + return count, err +} + +func (r *PostgreSQLRepository) GetNode(uuid string) (constant.Node, error) { + var n constant.Node + err := r.db.QueryRow(r.ctx, ` + SELECT + uuid, + name, + ARRAY( + SELECT squad_id + FROM node_to_squad + WHERE node_to_squad.node_uuid = nodes.uuid + ) as squad_ids, + created_at, + updated_at + FROM nodes + WHERE uuid = $1 + `, uuid).Scan( + &n.UUID, + &n.Name, + &n.SquadIDs, + &n.CreatedAt, + &n.UpdatedAt, + ) + if err != nil && err.Error() == "no rows in result set" { + return n, constant.ErrNotFound + } + return n, err +} + +func (r *PostgreSQLRepository) UpdateNode(uuid string, node constant.NodeUpdate) (constant.Node, error) { + var n constant.Node + err := r.db.QueryRow(r.ctx, ` + UPDATE nodes + SET + name = $1, + updated_at = $2 + WHERE uuid = $3 + RETURNING + uuid, + name, + created_at, + updated_at + `, + node.Name, + time.Now(), + uuid, + ).Scan( + &n.UUID, + &n.Name, + &n.CreatedAt, + &n.UpdatedAt, + ) + return n, err +} + +func (r *PostgreSQLRepository) DeleteNode(uuid string) (constant.Node, error) { + var n constant.Node + err := r.db.QueryRow(r.ctx, ` + DELETE FROM nodes + WHERE uuid = $1 + RETURNING + uuid, + name, + created_at, + updated_at + `, uuid).Scan( + &n.UUID, + &n.Name, + &n.CreatedAt, + &n.UpdatedAt, + ) + return n, err +} + +func (r *PostgreSQLRepository) CreateUser(user constant.UserCreate) (constant.User, error) { + var u constant.User + tx, err := r.db.Begin(r.ctx) + if err != nil { + return u, err + } + defer tx.Rollback(r.ctx) + now := time.Now() + err = tx.QueryRow(r.ctx, ` + INSERT INTO users ( + username, + type, + inbound, + uuid, + password, + flow, + alter_id, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING + id, + username, + type, + inbound, + uuid, + password, + flow, + alter_id, + created_at, + updated_at + `, + user.Username, + user.Type, + user.Inbound, + user.UUID, + user.Password, + user.Flow, + user.AlterID, + now, + now, + ).Scan( + &u.ID, + &u.Username, + &u.Type, + &u.Inbound, + &u.UUID, + &u.Password, + &u.Flow, + &u.AlterID, + &u.CreatedAt, + &u.UpdatedAt, + ) + rows := make([][]any, len(user.SquadIDs)) + for i, squadID := range user.SquadIDs { + rows[i] = []any{u.ID, squadID} + } + _, err = tx.CopyFrom( + r.ctx, + pgx.Identifier{"user_to_squad"}, + []string{"user_id", "squad_id"}, + pgx.CopyFromRows(rows), + ) + if err != nil { + return u, err + } + u.SquadIDs = user.SquadIDs + err = tx.Commit(r.ctx) + if err != nil { + return u, err + } + return u, err +} + +func (r *PostgreSQLRepository) GetUsers(filters map[string][]string) ([]constant.User, error) { + sb := sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select( + "id", + `ARRAY( + SELECT squad_id + FROM user_to_squad + WHERE user_to_squad.user_id = users.id + ) as squad_ids`, + "username", + "type", + "inbound", + "uuid", + "password", + "flow", + "alter_id", + "created_at", + "updated_at", + ). + From("users") + for key, value := range filters { + if filter, ok := userFilters[key]; ok { + if err := filter(sb, value); err != nil { + return nil, err + } + } + } + sql, args := sb.Build() + rows, err := r.db.Query(r.ctx, sql, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var result []constant.User + for rows.Next() { + var u constant.User + if err := rows.Scan( + &u.ID, + &u.SquadIDs, + &u.Username, + &u.Type, + &u.Inbound, + &u.UUID, + &u.Password, + &u.Flow, + &u.AlterID, + &u.CreatedAt, + &u.UpdatedAt, + ); err != nil { + return nil, err + } + result = append(result, u) + } + return result, rows.Err() +} + +func (r *PostgreSQLRepository) GetUsersCount(filters map[string][]string) (int, error) { + sb := sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select("COUNT(*)"). + From("users") + for key, value := range filters { + if filter, ok := userFilters[key]; ok { + if err := filter(sb, value); err != nil { + return 0, err + } + } + } + sql, args := sb.Build() + var count int + err := r.db.QueryRow(r.ctx, sql, args...).Scan(&count) + return count, err +} + +func (r *PostgreSQLRepository) GetUser(id int) (constant.User, error) { + var u constant.User + err := r.db.QueryRow(r.ctx, ` + SELECT + id, + ARRAY( + SELECT squad_id + FROM user_to_squad + WHERE user_to_squad.user_id = users.id + ) as squad_ids, + username, + type, + inbound, + uuid, + password, + flow, + alter_id, + created_at, + updated_at + FROM users + WHERE id = $1 + `, id).Scan( + &u.ID, + &u.SquadIDs, + &u.Username, + &u.Type, + &u.Inbound, + &u.UUID, + &u.Password, + &u.Flow, + &u.AlterID, + &u.CreatedAt, + &u.UpdatedAt, + ) + return u, err +} + +func (r *PostgreSQLRepository) UpdateUser(id int, user constant.UserUpdate) (constant.User, error) { + var u constant.User + err := r.db.QueryRow(r.ctx, ` + UPDATE users + SET + uuid = $1, + password = $2, + flow = $3, + alter_id = $4, + updated_at = $5 + WHERE id = $6 + RETURNING + id, + ARRAY( + SELECT squad_id + FROM user_to_squad + WHERE user_to_squad.user_id = users.id + ) as squad_ids, + username, + type, + inbound, + uuid, + password, + flow, + alter_id, + created_at, + updated_at + `, + user.UUID, + user.Password, + user.Flow, + user.AlterID, + time.Now(), + id, + ).Scan( + &u.ID, + &u.SquadIDs, + &u.Username, + &u.Type, + &u.Inbound, + &u.UUID, + &u.Password, + &u.Flow, + &u.AlterID, + &u.CreatedAt, + &u.UpdatedAt, + ) + return u, err +} + +func (r *PostgreSQLRepository) DeleteUser(id int) (constant.User, error) { + var u constant.User + err := r.db.QueryRow(r.ctx, ` + DELETE FROM users + WHERE id = $1 + RETURNING + id, + ARRAY( + SELECT squad_id + FROM user_to_squad + WHERE user_to_squad.user_id = users.id + ) as squad_ids, + username, + type, + inbound, + uuid, + password, + flow, + alter_id, + created_at, + updated_at + `, id).Scan( + &u.ID, + &u.SquadIDs, + &u.Username, + &u.Type, + &u.Inbound, + &u.UUID, + &u.Password, + &u.Flow, + &u.AlterID, + &u.CreatedAt, + &u.UpdatedAt, + ) + return u, err +} + +func (r *PostgreSQLRepository) CreateConnectionLimiter(limiter constant.ConnectionLimiterCreate) (constant.ConnectionLimiter, error) { + var cl constant.ConnectionLimiter + tx, err := r.db.Begin(r.ctx) + if err != nil { + return cl, err + } + defer tx.Rollback(r.ctx) + now := time.Now() + err = tx.QueryRow(r.ctx, ` + INSERT INTO connection_limiters + ( + username, + outbound, + strategy, + connection_type, + lock_type, + count, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING + id, + username, + outbound, + strategy, + connection_type, + lock_type, + count, + created_at, + updated_at + `, + limiter.Username, + limiter.Outbound, + limiter.Strategy, + limiter.ConnectionType, + limiter.LockType, + limiter.Count, + now, + now, + ).Scan( + &cl.ID, + &cl.Username, + &cl.Outbound, + &cl.Strategy, + &cl.ConnectionType, + &cl.LockType, + &cl.Count, + &cl.CreatedAt, + &cl.UpdatedAt, + ) + if err != nil { + return cl, err + } + rows := make([][]any, len(limiter.SquadIDs)) + for i, squadID := range limiter.SquadIDs { + rows[i] = []any{cl.ID, squadID} + } + _, err = tx.CopyFrom( + r.ctx, + pgx.Identifier{"connection_limiter_to_squad"}, + []string{"connection_limiter_id", "squad_id"}, + pgx.CopyFromRows(rows), + ) + if err != nil { + return cl, err + } + cl.SquadIDs = limiter.SquadIDs + err = tx.Commit(r.ctx) + if err != nil { + return cl, err + } + return cl, err +} + +func (r *PostgreSQLRepository) GetConnectionLimiter(id int) (constant.ConnectionLimiter, error) { + var cl constant.ConnectionLimiter + err := r.db.QueryRow(r.ctx, ` + SELECT + id, + ARRAY( + SELECT squad_id + FROM connection_limiter_to_squad + WHERE connection_limiter_to_squad.connection_limiter_id = connection_limiters.id + ) as squad_ids, + username, + outbound, + strategy, + connection_type, + lock_type, + count, + created_at, + updated_at + FROM connection_limiters + WHERE id=$1 + `, id).Scan( + &cl.ID, + &cl.SquadIDs, + &cl.Username, + &cl.Outbound, + &cl.Strategy, + &cl.ConnectionType, + &cl.LockType, + &cl.Count, + &cl.CreatedAt, + &cl.UpdatedAt, + ) + return cl, err +} + +func (r *PostgreSQLRepository) GetConnectionLimiters(filters map[string][]string) ([]constant.ConnectionLimiter, error) { + sb := sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select( + "id", + `ARRAY( + SELECT squad_id + FROM connection_limiter_to_squad + WHERE connection_limiter_to_squad.connection_limiter_id = connection_limiters.id + ) as squad_ids`, + "username", + "outbound", + "strategy", + "connection_type", + "lock_type", + "count", + "created_at", + "updated_at", + ). + From("connection_limiters") + for k, v := range filters { + if f, ok := connectionLimiterFilters[k]; ok { + if err := f(sb, v); err != nil { + return nil, err + } + } + } + sql, args := sb.Build() + rows, err := r.db.Query(r.ctx, sql, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var result []constant.ConnectionLimiter + for rows.Next() { + var cl constant.ConnectionLimiter + if err := rows.Scan( + &cl.ID, + &cl.SquadIDs, + &cl.Username, + &cl.Outbound, + &cl.Strategy, + &cl.ConnectionType, + &cl.LockType, + &cl.Count, + &cl.CreatedAt, + &cl.UpdatedAt, + ); err != nil { + return nil, err + } + result = append(result, cl) + } + + return result, rows.Err() +} + +func (r *PostgreSQLRepository) GetConnectionLimitersCount(filters map[string][]string) (int, error) { + sb := sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select("COUNT(*)"). + From("connection_limiters") + + for k, v := range filters { + if f, ok := connectionLimiterFilters[k]; ok { + if err := f(sb, v); err != nil { + return 0, err + } + } + } + sql, args := sb.Build() + var count int + err := r.db.QueryRow(r.ctx, sql, args...).Scan(&count) + return count, err +} + +func (r *PostgreSQLRepository) UpdateConnectionLimiter(id int, limiter constant.ConnectionLimiterUpdate) (constant.ConnectionLimiter, error) { + var cl constant.ConnectionLimiter + err := r.db.QueryRow(r.ctx, ` + UPDATE connection_limiters + SET + strategy=$1, + connection_type=$2, + lock_type=$3, + count=$4, + updated_at=$5 + WHERE id=$6 + RETURNING + id, + ARRAY( + SELECT squad_id + FROM connection_limiter_to_squad + WHERE connection_limiter_to_squad.connection_limiter_id = connection_limiters.id + ) as squad_ids, + username, + outbound, + strategy, + connection_type, + lock_type, + count, + created_at, + updated_at + `, + limiter.Strategy, + limiter.ConnectionType, + limiter.LockType, + limiter.Count, + time.Now(), + id, + ).Scan( + &cl.ID, + &cl.SquadIDs, + &cl.Username, + &cl.Outbound, + &cl.Strategy, + &cl.ConnectionType, + &cl.LockType, + &cl.Count, + &cl.CreatedAt, + &cl.UpdatedAt, + ) + return cl, err +} + +func (r *PostgreSQLRepository) DeleteConnectionLimiter(id int) (constant.ConnectionLimiter, error) { + var cl constant.ConnectionLimiter + err := r.db.QueryRow(r.ctx, ` + DELETE FROM connection_limiters + WHERE id=$1 + RETURNING + id, + ARRAY( + SELECT squad_id + FROM connection_limiter_to_squad + WHERE connection_limiter_to_squad.connection_limiter_id = connection_limiters.id + ) as squad_ids, + username, + outbound, + strategy, + connection_type, + lock_type, + count, + created_at, + updated_at + `, id).Scan( + &cl.ID, + &cl.SquadIDs, + &cl.Username, + &cl.Outbound, + &cl.Strategy, + &cl.ConnectionType, + &cl.LockType, + &cl.Count, + &cl.CreatedAt, + &cl.UpdatedAt, + ) + return cl, err +} + +func (r *PostgreSQLRepository) CreateBandwidthLimiter(limiter constant.BandwidthLimiterCreate) (constant.BandwidthLimiter, error) { + var bl constant.BandwidthLimiter + tx, err := r.db.Begin(r.ctx) + if err != nil { + return bl, err + } + defer tx.Rollback(r.ctx) + bytesSpeed, err := json.Marshal(limiter.Speed) + if err != nil { + return bl, err + } + raw := &byteformats.NetworkBytesCompat{} + if err = raw.UnmarshalJSON(bytesSpeed); err != nil { + return bl, err + } + now := time.Now() + err = tx.QueryRow(r.ctx, ` + INSERT INTO bandwidth_limiters + ( + username, + outbound, + strategy, + mode, + connection_type, + speed, + raw_speed, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING + id, + username, + outbound, + strategy, + mode, + connection_type, + speed, + raw_speed, + created_at, + updated_at + `, + limiter.Username, + limiter.Outbound, + limiter.Strategy, + limiter.Mode, + limiter.ConnectionType, + limiter.Speed, + raw.Value(), + now, + now, + ).Scan( + &bl.ID, + &bl.Username, + &bl.Outbound, + &bl.Strategy, + &bl.Mode, + &bl.ConnectionType, + &bl.Speed, + &bl.RawSpeed, + &bl.CreatedAt, + &bl.UpdatedAt, + ) + if err != nil { + return bl, err + } + rows := make([][]any, len(limiter.SquadIDs)) + for i, squadID := range limiter.SquadIDs { + rows[i] = []any{bl.ID, squadID} + } + _, err = tx.CopyFrom( + r.ctx, + pgx.Identifier{"bandwidth_limiter_to_squad"}, + []string{"bandwidth_limiter_id", "squad_id"}, + pgx.CopyFromRows(rows), + ) + if err != nil { + return bl, err + } + bl.SquadIDs = limiter.SquadIDs + err = tx.Commit(r.ctx) + if err != nil { + return bl, err + } + return bl, err +} + +func (r *PostgreSQLRepository) GetBandwidthLimiter(id int) (constant.BandwidthLimiter, error) { + var bl constant.BandwidthLimiter + err := r.db.QueryRow(r.ctx, ` + SELECT + id, + ARRAY( + SELECT squad_id + FROM bandwidth_limiter_to_squad + WHERE bandwidth_limiter_to_squad.bandwidth_limiter_id = bandwidth_limiters.id + ) as squad_ids, + username, + outbound, + strategy, + mode, + connection_type, + speed, + raw_speed, + created_at, + updated_at + FROM bandwidth_limiters + WHERE id=$1 + `, id).Scan( + &bl.ID, + &bl.SquadIDs, + &bl.Username, + &bl.Outbound, + &bl.Strategy, + &bl.Mode, + &bl.ConnectionType, + &bl.Speed, + &bl.RawSpeed, + &bl.CreatedAt, + &bl.UpdatedAt, + ) + return bl, err +} + +func (r *PostgreSQLRepository) GetBandwidthLimiters(filters map[string][]string) ([]constant.BandwidthLimiter, error) { + sb := sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select( + "id", + `ARRAY( + SELECT squad_id + FROM bandwidth_limiter_to_squad + WHERE bandwidth_limiter_to_squad.bandwidth_limiter_id = bandwidth_limiters.id + ) as squad_ids`, + "username", + "outbound", + "strategy", + "mode", + "connection_type", + "speed", + "raw_speed", + "created_at", + "updated_at", + ). + From("bandwidth_limiters") + + for k, v := range filters { + if f, ok := bandwidthLimiterFilters[k]; ok { + if err := f(sb, v); err != nil { + return nil, err + } + } + } + sql, args := sb.Build() + rows, err := r.db.Query(r.ctx, sql, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var result []constant.BandwidthLimiter + for rows.Next() { + var bl constant.BandwidthLimiter + if err := rows.Scan( + &bl.ID, + &bl.SquadIDs, + &bl.Username, + &bl.Outbound, + &bl.Strategy, + &bl.Mode, + &bl.ConnectionType, + &bl.Speed, + &bl.RawSpeed, + &bl.CreatedAt, + &bl.UpdatedAt, + ); err != nil { + return nil, err + } + result = append(result, bl) + } + return result, rows.Err() +} + +func (r *PostgreSQLRepository) GetBandwidthLimitersCount(filters map[string][]string) (int, error) { + sb := sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select("COUNT(*)"). + From("bandwidth_limiters") + for k, v := range filters { + if f, ok := bandwidthLimiterFilters[k]; ok { + if err := f(sb, v); err != nil { + return 0, err + } + } + } + sql, args := sb.Build() + var count int + err := r.db.QueryRow(r.ctx, sql, args...).Scan(&count) + return count, err +} + +func (r *PostgreSQLRepository) UpdateBandwidthLimiter(id int, limiter constant.BandwidthLimiterUpdate) (constant.BandwidthLimiter, error) { + var bl constant.BandwidthLimiter + bytesSpeed, err := json.Marshal(limiter.Speed) + if err != nil { + return bl, err + } + raw := &byteformats.NetworkBytesCompat{} + if err = raw.UnmarshalJSON(bytesSpeed); err != nil { + return bl, err + } + err = r.db.QueryRow(r.ctx, ` + UPDATE bandwidth_limiters + SET + username=$1, + outbound=$2, + strategy=$3, + mode=$4, + connection_type=$5, + speed=$6, + raw_speed=$7, + updated_at=$8 + WHERE id=$9 + RETURNING + id, + ARRAY( + SELECT squad_id + FROM bandwidth_limiter_to_squad + WHERE bandwidth_limiter_to_squad.bandwidth_limiter_id = bandwidth_limiters.id + ) as squad_ids, + username, + outbound, + strategy, + mode, + connection_type, + speed, + raw_speed, + created_at, + updated_at + `, + limiter.Username, + limiter.Outbound, + limiter.Strategy, + limiter.Mode, + limiter.ConnectionType, + limiter.Speed, + raw.Value(), + time.Now(), + id, + ).Scan( + &bl.ID, + &bl.SquadIDs, + &bl.Username, + &bl.Outbound, + &bl.Strategy, + &bl.Mode, + &bl.ConnectionType, + &bl.Speed, + &bl.RawSpeed, + &bl.CreatedAt, + &bl.UpdatedAt, + ) + return bl, err +} + +func (r *PostgreSQLRepository) DeleteBandwidthLimiter(id int) (constant.BandwidthLimiter, error) { + var bl constant.BandwidthLimiter + err := r.db.QueryRow(r.ctx, ` + DELETE FROM bandwidth_limiters + WHERE id=$1 + RETURNING + id, + ARRAY( + SELECT squad_id + FROM bandwidth_limiter_to_squad + WHERE bandwidth_limiter_to_squad.bandwidth_limiter_id = bandwidth_limiters.id + ) as squad_ids, + username, + outbound, + strategy, + mode, + connection_type, + speed, + raw_speed, + created_at, + updated_at + `, id).Scan( + &bl.ID, + &bl.SquadIDs, + &bl.Username, + &bl.Outbound, + &bl.Strategy, + &bl.Mode, + &bl.ConnectionType, + &bl.Speed, + &bl.RawSpeed, + &bl.CreatedAt, + &bl.UpdatedAt, + ) + return bl, err +} + +func init() { + squadFilters = map[string]Filter{ + "id": EqualFilter("id"), + "pk": EqualFilter("id"), + "name": EqualFilter("name"), + "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(), + "offset": OffsetFilter(), + "limit": LimitFilter(), + } + nodeFilters = map[string]Filter{ + "uuid": EqualFilter("uuid"), + "pk": EqualFilter("uuid"), + "name": EqualFilter("name"), + "squad_id_in": ExistsAndWhereInFilter( + sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select( + "squad_id", + ). + Where( + "node_to_squad.node_uuid = nodes.uuid", + ). + From( + "node_to_squad", + ), + "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(), + "offset": OffsetFilter(), + "limit": LimitFilter(), + } + userFilters = map[string]Filter{ + "id": EqualFilter("id"), + "pk": EqualFilter("id"), + "squad_id_in": ExistsAndWhereInFilter( + sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select( + "squad_id", + ). + Where( + "user_to_squad.user_id = users.id", + ). + From( + "user_to_squad", + ), + "user_to_squad.squad_id", + ), + "username": EqualFilter("username"), + "type": EqualFilter("type"), + "inbound": EqualFilter("inbound"), + "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(), + "offset": OffsetFilter(), + "limit": LimitFilter(), + } + connectionLimiterFilters = map[string]Filter{ + "id": EqualFilter("id"), + "pk": EqualFilter("id"), + "squad_id_in": ExistsAndWhereInFilter( + sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select( + "squad_id", + ). + Where( + "connection_limiter_to_squad.connection_limiter_id = connection_limiters.id", + ). + From( + "connection_limiter_to_squad", + ), + "connection_limiter_to_squad.squad_id", + ), + "strategy": EqualFilter("strategy"), + "username": EqualFilter("username"), + "outbound": EqualFilter("outbound"), + "connection_type": EqualFilter("connection_type"), + "lock_type": EqualFilter("lock_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(), + "offset": OffsetFilter(), + "limit": LimitFilter(), + } + bandwidthLimiterFilters = map[string]Filter{ + "id": EqualFilter("id"), + "pk": EqualFilter("id"), + "squad_id_in": ExistsAndWhereInFilter( + sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select( + "squad_id", + ). + Where( + "bandwidth_limiter_to_squad.bandwidth_limiter_id = bandwidth_limiters.id", + ). + From( + "bandwidth_limiter_to_squad", + ), + "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(), + } +} diff --git a/service/manager/service.go b/service/manager/service.go new file mode 100644 index 00000000..b73c75b0 --- /dev/null +++ b/service/manager/service.go @@ -0,0 +1,598 @@ +//go:build with_manager + +package manager + +import ( + "context" + "strconv" + "sync" + "time" + + "github.com/go-playground/validator/v10" + "github.com/gofrs/uuid/v5" + "github.com/patrickmn/go-cache" + "github.com/sagernet/sing-box/adapter" + boxService "github.com/sagernet/sing-box/adapter/service" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/manager/repository/postgresql" + E "github.com/sagernet/sing/common/exceptions" +) + +func RegisterService(registry *boxService.Registry) { + boxService.Register[option.ManagerServiceOptions](registry, C.TypeManager, NewService) +} + +type Service struct { + boxService.Adapter + ctx context.Context + logger log.ContextLogger + repository constant.Repository + nodes map[string]constant.ConnectedNode + + limiterLocks map[int]map[string]*cache.Cache + + userValidator *validator.Validate + defaultValidator *validator.Validate + + mtx sync.RWMutex + connLockMtx sync.Mutex +} + +func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.ManagerServiceOptions) (adapter.Service, error) { + var repository constant.Repository + var err error + switch options.Database.Driver { + case "postgresql": + repository, err = postgresql.NewPostgreSQLRepository(ctx, options.Database.DSN) + if err != nil { + return nil, err + } + default: + return nil, E.New("unknown driver \"", options.Database.Driver, "\"") + } + userValidator := validator.New() + userValidator.RegisterStructValidation(func(sl validator.StructLevel) { + user := sl.Current().Interface().(constant.UserCreate) + switch user.Type { + case "vless": + if user.UUID == "" { + sl.ReportError(user.UUID, "uuid", "UUID", "required", "") + } + case "vmess": + if user.UUID == "" { + sl.ReportError(user.UUID, "uuid", "UUID", "required", "") + } + if user.AlterID == 0 { + sl.ReportError(user.AlterID, "alter_id", "AlterID", "required", "") + } + case "trojan", "shadowsocks", "hysteria", "hysteria2": + if user.Password == "" { + sl.ReportError(user.Password, "password", "Password", "required", "") + } + case "tuic": + if user.UUID == "" { + sl.ReportError(user.UUID, "uuid", "UUID", "required", "") + } + if user.Password == "" { + sl.ReportError(user.Password, "password", "Password", "required", "") + } + } + }, constant.UserCreate{}) + return &Service{ + Adapter: boxService.NewAdapter(C.TypeManager, tag), + ctx: ctx, + logger: logger, + repository: repository, + nodes: make(map[string]constant.ConnectedNode, 0), + limiterLocks: make(map[int]map[string]*cache.Cache), + userValidator: userValidator, + defaultValidator: validator.New(), + }, nil +} + +func (s *Service) CreateSquad(node constant.SquadCreate) (constant.Squad, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + err := s.defaultValidator.Struct(node) + if err != nil { + return constant.Squad{}, err + } + createdSquad, err := s.repository.CreateSquad(node) + if err != nil { + return createdSquad, err + } + return createdSquad, nil +} + +func (s *Service) GetSquads(filters map[string][]string) ([]constant.Squad, error) { + return s.repository.GetSquads(filters) +} + +func (s *Service) GetSquadsCount(filters map[string][]string) (int, error) { + return s.repository.GetSquadsCount(filters) +} + +func (s *Service) GetSquad(id int) (constant.Squad, error) { + return s.repository.GetSquad(id) +} + +func (s *Service) UpdateSquad(id int, squad constant.SquadUpdate) (constant.Squad, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + err := s.defaultValidator.Struct(squad) + if err != nil { + return constant.Squad{}, err + } + updatedSquad, err := s.repository.UpdateSquad(id, squad) + if err != nil { + return updatedSquad, err + } + return updatedSquad, nil +} + +func (s *Service) DeleteSquad(id int) (constant.Squad, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + deletedSquad, err := s.repository.DeleteSquad(id) + if err != nil { + return deletedSquad, err + } + return deletedSquad, nil +} + +func (s *Service) CreateNode(node constant.NodeCreate) (constant.Node, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + err := s.defaultValidator.Struct(node) + if err != nil { + return constant.Node{}, err + } + createdNode, err := s.repository.CreateNode(node) + if err != nil { + return createdNode, err + } + return createdNode, nil +} + +func (s *Service) GetNodes(filters map[string][]string) ([]constant.Node, error) { + return s.repository.GetNodes(filters) +} + +func (s *Service) GetNodesCount(filters map[string][]string) (int, error) { + return s.repository.GetNodesCount(filters) +} + +func (s *Service) GetNode(uuid string) (constant.Node, error) { + return s.repository.GetNode(uuid) +} + +func (s *Service) GetNodeStatus(uuid string) string { + s.mtx.RLock() + defer s.mtx.RUnlock() + node, ok := s.nodes[uuid] + if !ok || !node.IsOnline() { + return "offline" + } + return "online" +} + +func (s *Service) UpdateNode(uuid string, node constant.NodeUpdate) (constant.Node, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + err := s.defaultValidator.Struct(node) + if err != nil { + return constant.Node{}, err + } + updatedNode, err := s.repository.UpdateNode(uuid, node) + if err != nil { + return updatedNode, err + } + return updatedNode, nil +} + +func (s *Service) DeleteNode(uuid string) (constant.Node, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + deletedNode, err := s.repository.DeleteNode(uuid) + if err != nil { + return deletedNode, err + } + node, ok := s.nodes[uuid] + if ok { + node.Close() + delete(s.nodes, uuid) + } + return deletedNode, nil +} + +func (s *Service) CreateUser(user constant.UserCreate) (constant.User, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + err := s.userValidator.Struct(user) + if err != nil { + return constant.User{}, err + } + createdUser, err := s.repository.CreateUser(user) + if err != nil { + return createdUser, err + } + nodes, err := s.repository.GetNodes(map[string][]string{ + "squad_id_in": convertIntSliceToStringSlice(createdUser.SquadIDs), + }) + if err != nil { + s.closeAllNodes() + return createdUser, err + } + for _, node := range nodes { + if node, ok := s.nodes[node.UUID]; ok { + node.UpdateUser(createdUser) + } + } + return createdUser, nil +} + +func (s *Service) GetUsers(filters map[string][]string) ([]constant.User, error) { + return s.repository.GetUsers(filters) +} + +func (s *Service) GetUsersCount(filters map[string][]string) (int, error) { + return s.repository.GetUsersCount(filters) +} + +func (s *Service) GetUser(id int) (constant.User, error) { + return s.repository.GetUser(id) +} + +func (s *Service) UpdateUser(id int, user constant.UserUpdate) (constant.User, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + updatedUser, err := s.repository.UpdateUser(id, user) + if err != nil { + return updatedUser, err + } + nodes, err := s.repository.GetNodes(map[string][]string{ + "squad_id_in": convertIntSliceToStringSlice(updatedUser.SquadIDs), + }) + if err != nil { + s.closeAllNodes() + return updatedUser, err + } + for _, node := range nodes { + if node, ok := s.nodes[node.UUID]; ok { + node.UpdateUser(updatedUser) + } + } + return updatedUser, nil +} + +func (s *Service) DeleteUser(id int) (constant.User, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + deletedUser, err := s.repository.DeleteUser(id) + if err != nil { + return deletedUser, err + } + nodes, err := s.repository.GetNodes(map[string][]string{ + "squad_id_in": convertIntSliceToStringSlice(deletedUser.SquadIDs), + }) + if err != nil { + s.closeAllNodes() + return deletedUser, err + } + for _, node := range nodes { + if node, ok := s.nodes[node.UUID]; ok { + node.DeleteUser(deletedUser) + } + } + return deletedUser, nil +} + +func (s *Service) CreateConnectionLimiter(limiter constant.ConnectionLimiterCreate) (constant.ConnectionLimiter, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + err := s.defaultValidator.Struct(limiter) + if err != nil { + return constant.ConnectionLimiter{}, err + } + createdLimiter, err := s.repository.CreateConnectionLimiter(limiter) + if err != nil { + return createdLimiter, err + } + nodes, err := s.repository.GetNodes(map[string][]string{ + "squad_id_in": convertIntSliceToStringSlice(createdLimiter.SquadIDs), + }) + if err != nil { + s.closeAllNodes() + return createdLimiter, err + } + for _, node := range nodes { + if node, ok := s.nodes[node.UUID]; ok { + node.UpdateConnectionLimiter(createdLimiter) + } + } + return createdLimiter, nil +} + +func (s *Service) GetConnectionLimiters(filters map[string][]string) ([]constant.ConnectionLimiter, error) { + return s.repository.GetConnectionLimiters(filters) +} + +func (s *Service) GetConnectionLimitersCount(filters map[string][]string) (int, error) { + return s.repository.GetConnectionLimitersCount(filters) +} + +func (s *Service) GetConnectionLimiter(id int) (constant.ConnectionLimiter, error) { + return s.repository.GetConnectionLimiter(id) +} + +func (s *Service) UpdateConnectionLimiter(id int, limiter constant.ConnectionLimiterUpdate) (constant.ConnectionLimiter, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + err := s.defaultValidator.Struct(limiter) + if err != nil { + return constant.ConnectionLimiter{}, err + } + updatedLimiter, err := s.repository.UpdateConnectionLimiter(id, limiter) + if err != nil { + return updatedLimiter, err + } + nodes, err := s.repository.GetNodes(map[string][]string{ + "squad_id_in": convertIntSliceToStringSlice(updatedLimiter.SquadIDs), + }) + if err != nil { + s.closeAllNodes() + return updatedLimiter, err + } + for _, node := range nodes { + if node, ok := s.nodes[node.UUID]; ok { + node.UpdateConnectionLimiter(updatedLimiter) + } + } + if limiter.LockType != "manager" { + s.connLockMtx.Lock() + defer s.connLockMtx.Unlock() + delete(s.limiterLocks, id) + } + return updatedLimiter, nil +} + +func (s *Service) DeleteConnectionLimiter(id int) (constant.ConnectionLimiter, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + deletedLimiter, err := s.repository.DeleteConnectionLimiter(id) + if err != nil { + return deletedLimiter, err + } + nodes, err := s.repository.GetNodes(map[string][]string{ + "squad_id_in": convertIntSliceToStringSlice(deletedLimiter.SquadIDs), + }) + if err != nil { + s.closeAllNodes() + return deletedLimiter, err + } + for _, node := range nodes { + if node, ok := s.nodes[node.UUID]; ok { + node.DeleteConnectionLimiter(deletedLimiter) + } + } + if deletedLimiter.LockType == "manager" { + s.connLockMtx.Lock() + defer s.connLockMtx.Unlock() + delete(s.limiterLocks, id) + } + return deletedLimiter, nil +} + +func (s *Service) CreateBandwidthLimiter(limiter constant.BandwidthLimiterCreate) (constant.BandwidthLimiter, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + err := s.defaultValidator.Struct(limiter) + if err != nil { + return constant.BandwidthLimiter{}, err + } + createdLimiter, err := s.repository.CreateBandwidthLimiter(limiter) + if err != nil { + return createdLimiter, err + } + nodes, err := s.repository.GetNodes(map[string][]string{ + "squad_id_in": convertIntSliceToStringSlice(createdLimiter.SquadIDs), + }) + if err != nil { + s.closeAllNodes() + return createdLimiter, err + } + for _, node := range nodes { + if node, ok := s.nodes[node.UUID]; ok { + node.UpdateBandwidthLimiter(createdLimiter) + } + } + return createdLimiter, nil +} + +func (s *Service) GetBandwidthLimiters(filters map[string][]string) ([]constant.BandwidthLimiter, error) { + return s.repository.GetBandwidthLimiters(filters) +} + +func (s *Service) GetBandwidthLimitersCount(filters map[string][]string) (int, error) { + return s.repository.GetBandwidthLimitersCount(filters) +} + +func (s *Service) GetBandwidthLimiter(id int) (constant.BandwidthLimiter, error) { + return s.repository.GetBandwidthLimiter(id) +} + +func (s *Service) UpdateBandwidthLimiter(id int, limiter constant.BandwidthLimiterUpdate) (constant.BandwidthLimiter, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + err := s.defaultValidator.Struct(limiter) + if err != nil { + return constant.BandwidthLimiter{}, err + } + updatedLimiter, err := s.repository.UpdateBandwidthLimiter(id, limiter) + if err != nil { + return updatedLimiter, err + } + nodes, err := s.repository.GetNodes(map[string][]string{ + "squad_id_in": convertIntSliceToStringSlice(updatedLimiter.SquadIDs), + }) + if err != nil { + s.closeAllNodes() + return updatedLimiter, err + } + for _, node := range nodes { + if node, ok := s.nodes[node.UUID]; ok { + node.UpdateBandwidthLimiter(updatedLimiter) + } + } + return updatedLimiter, nil +} + +func (s *Service) DeleteBandwidthLimiter(id int) (constant.BandwidthLimiter, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + deletedLimiter, err := s.repository.DeleteBandwidthLimiter(id) + if err != nil { + return deletedLimiter, err + } + nodes, err := s.repository.GetNodes(map[string][]string{ + "squad_id_in": convertIntSliceToStringSlice(deletedLimiter.SquadIDs), + }) + if err != nil { + s.closeAllNodes() + return deletedLimiter, err + } + for _, node := range nodes { + if node, ok := s.nodes[node.UUID]; ok { + node.DeleteBandwidthLimiter(deletedLimiter) + } + } + return deletedLimiter, nil +} + +func (s *Service) AddNode(uuid string, node constant.ConnectedNode) error { + s.mtx.Lock() + defer s.mtx.Unlock() + var node_ constant.Node + var err error + node_, err = s.repository.GetNode(uuid) + if err != nil { + return err + } + squadIDs := convertIntSliceToStringSlice(node_.SquadIDs) + users, err := s.repository.GetUsers(map[string][]string{ + "squad_id_in": squadIDs, + }) + if err != nil { + return err + } + node.UpdateUsers(users) + bandwidthLimiters, err := s.repository.GetBandwidthLimiters(map[string][]string{ + "squad_id_in": squadIDs, + }) + if err != nil { + return err + } + node.UpdateBandwidthLimiters(bandwidthLimiters) + connectionLimiters, err := s.repository.GetConnectionLimiters(map[string][]string{ + "squad_id_in": squadIDs, + }) + if err != nil { + return err + } + node.UpdateConnectionLimiters(connectionLimiters) + s.nodes[uuid] = node + return nil +} + +func (s *Service) AcquireLock(limiterId int, id string) (string, error) { + s.connLockMtx.Lock() + defer s.connLockMtx.Unlock() + limiter, err := s.repository.GetConnectionLimiter(limiterId) + if err != nil { + return "", err + } + if limiter.LockType != "manager" { + return "", E.New("invalid lock type") + } + locks, ok := s.limiterLocks[limiterId] + if !ok { + locks = make(map[string]*cache.Cache) + s.limiterLocks[limiter.ID] = locks + } + lock, ok := locks[id] + if !ok { + if len(locks) == int(limiter.Count) { + return "", E.New("not enough free locks") + } + lock = cache.New(time.Second*30, time.Second) + lock.OnEvicted(func(_ string, _ interface{}) { + s.connLockMtx.Lock() + defer s.connLockMtx.Unlock() + if lock.ItemCount() == 0 { + delete(locks, id) + } + }) + locks[id] = lock + } + handleID, err := uuid.NewV4() + if err != nil { + return "", err + } + lock.SetDefault(handleID.String(), new(struct{})) + return handleID.String(), nil +} + +func (s *Service) RefreshLock(limiterId int, id string, handleId string) error { + s.connLockMtx.Lock() + defer s.connLockMtx.Unlock() + locks, ok := s.limiterLocks[limiterId] + if !ok { + return E.New("limiter not found") + } + lock, ok := locks[id] + if !ok { + return E.New("lock not found") + } + err := lock.Replace(handleId, new(struct{}), time.Second*30) + return err +} + +func (s *Service) ReleaseLock(limiterId int, id string, handleId string) error { + s.connLockMtx.Lock() + defer s.connLockMtx.Unlock() + locks, ok := s.limiterLocks[limiterId] + if !ok { + return E.New("limiter not found") + } + lock, ok := locks[id] + if !ok { + return E.New("lock not found") + } + go lock.Delete(handleId) + return nil +} + +func (s *Service) Start(stage adapter.StartStage) error { + return nil +} + +func (s *Service) Close() error { + return nil +} + +func (s *Service) closeAllNodes() { + for _, node := range s.nodes { + node.Close() + } +} + +func convertIntSliceToStringSlice(values []int) []string { + result := make([]string, len(values)) + for i, v := range values { + result[i] = strconv.Itoa(v) + } + return result +} diff --git a/service/manager/service_stub.go b/service/manager/service_stub.go new file mode 100644 index 00000000..cb22815a --- /dev/null +++ b/service/manager/service_stub.go @@ -0,0 +1,20 @@ +//go:build !with_manager + +package manager + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/service" + 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 RegisterService(registry *service.Registry) { + service.Register[option.ManagerServiceOptions](registry, C.TypeManager, func(ctx context.Context, logger log.ContextLogger, tag string, options option.ManagerServiceOptions) (adapter.Service, error) { + return nil, E.New(`Manager is not included in this build, rebuild with -tags with_manager`) + }) +} diff --git a/service/node/constant/bandwidth.go b/service/node/constant/bandwidth.go new file mode 100644 index 00000000..29988ee0 --- /dev/null +++ b/service/node/constant/bandwidth.go @@ -0,0 +1,18 @@ +package constant + +import ( + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/service/manager/constant" +) + +type BandwidthLimiterManager interface { + AddBandwidthLimiterStrategyManager(outbound adapter.Outbound) error + GetBandwidthLimiterStrategyManager(tag string) (BandwidthLimiterStrategyManager, bool) + GetBandwidthLimiterStrategyManagerTags() []string +} + +type BandwidthLimiterStrategyManager interface { + UpdateBandwidthLimiter(limiter C.BandwidthLimiter) + UpdateBandwidthLimiters(limiter []C.BandwidthLimiter) + DeleteBandwidthLimiter(username string) +} diff --git a/service/node/constant/connection.go b/service/node/constant/connection.go new file mode 100644 index 00000000..9b4a5c82 --- /dev/null +++ b/service/node/constant/connection.go @@ -0,0 +1,18 @@ +package constant + +import ( + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/service/manager/constant" +) + +type ConnectionLimiterManager interface { + AddConnectionLimiterStrategyManager(outbound adapter.Outbound) error + GetConnectionLimiterStrategyManager(tag string) (ConnectionLimiterStrategyManager, bool) + GetConnectionLimiterStrategyManagerTags() []string +} + +type ConnectionLimiterStrategyManager interface { + UpdateConnectionLimiter(limiter C.ConnectionLimiter) + UpdateConnectionLimiters(limiter []C.ConnectionLimiter) + DeleteConnectionLimiter(username string) +} diff --git a/service/node/constant/inbound.go b/service/node/constant/inbound.go new file mode 100644 index 00000000..d65f10fb --- /dev/null +++ b/service/node/constant/inbound.go @@ -0,0 +1,18 @@ +package constant + +import ( + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/service/manager/constant" +) + +type InboundManager interface { + AddUserManager(inbound adapter.Inbound) error + GetUserManager(tag string) (UserManager, bool) + GetUserManagerTags() []string +} + +type UserManager interface { + UpdateUser(user C.User) + UpdateUsers(users []C.User) + DeleteUser(username string) +} diff --git a/service/node/inbound/hysteria.go b/service/node/inbound/hysteria.go new file mode 100644 index 00000000..7d5d365c --- /dev/null +++ b/service/node/inbound/hysteria.go @@ -0,0 +1,88 @@ +package inbound + +import ( + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/hysteria" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" +) + +type HysteriaManager struct { + access sync.Mutex + inbounds map[string]*HysteriaUserManager +} + +func NewHysteriaManager() *HysteriaManager { + return &HysteriaManager{ + inbounds: make(map[string]*HysteriaUserManager), + } +} + +func (m *HysteriaManager) AddUserManager(inbound adapter.Inbound) error { + m.access.Lock() + defer m.access.Unlock() + m.inbounds[inbound.Tag()] = &HysteriaUserManager{ + inbound: inbound.(*hysteria.Inbound), + usersMap: make(map[string]option.HysteriaUser), + } + return nil +} + +func (m *HysteriaManager) GetUserManager(tag string) (constant.UserManager, bool) { + m.access.Lock() + defer m.access.Unlock() + inbound, ok := m.inbounds[tag] + return inbound, ok +} + +func (m *HysteriaManager) GetUserManagerTags() []string { + m.access.Lock() + defer m.access.Unlock() + tags := make([]string, 0, len(m.inbounds)) + for tag, _ := range m.inbounds { + tags = append(tags, tag) + } + return tags +} + +type HysteriaUserManager struct { + inbound *hysteria.Inbound + usersMap map[string]option.HysteriaUser + + mtx sync.Mutex +} + +func (i *HysteriaUserManager) postUpdate() { + users := make([]option.HysteriaUser, 0, len(i.usersMap)) + for _, user := range i.usersMap { + users = append(users, user) + } + i.inbound.UpdateUsers(users) +} + +func (i *HysteriaUserManager) UpdateUser(user CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + i.usersMap[user.Username] = option.HysteriaUser{Name: user.Username, AuthString: user.Password} + i.postUpdate() +} + +func (i *HysteriaUserManager) UpdateUsers(users []CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.usersMap) + for _, user := range users { + i.usersMap[user.Username] = option.HysteriaUser{Name: user.Username, AuthString: user.Password} + } + i.postUpdate() +} + +func (i *HysteriaUserManager) DeleteUser(username string) { + i.mtx.Lock() + defer i.mtx.Unlock() + delete(i.usersMap, username) + i.postUpdate() +} diff --git a/service/node/inbound/hysteria2.go b/service/node/inbound/hysteria2.go new file mode 100644 index 00000000..5f65cf22 --- /dev/null +++ b/service/node/inbound/hysteria2.go @@ -0,0 +1,88 @@ +package inbound + +import ( + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/hysteria2" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" +) + +type Hysteria2Manager struct { + access sync.Mutex + inbounds map[string]*Hysteria2UserManager +} + +func NewHysteria2Manager() *Hysteria2Manager { + return &Hysteria2Manager{ + inbounds: make(map[string]*Hysteria2UserManager), + } +} + +func (m *Hysteria2Manager) AddUserManager(inbound adapter.Inbound) error { + m.access.Lock() + defer m.access.Unlock() + m.inbounds[inbound.Tag()] = &Hysteria2UserManager{ + inbound: inbound.(*hysteria2.Inbound), + usersMap: make(map[string]option.Hysteria2User), + } + return nil +} + +func (m *Hysteria2Manager) GetUserManager(tag string) (constant.UserManager, bool) { + m.access.Lock() + defer m.access.Unlock() + inbound, ok := m.inbounds[tag] + return inbound, ok +} + +func (m *Hysteria2Manager) GetUserManagerTags() []string { + m.access.Lock() + defer m.access.Unlock() + tags := make([]string, 0, len(m.inbounds)) + for tag, _ := range m.inbounds { + tags = append(tags, tag) + } + return tags +} + +type Hysteria2UserManager struct { + inbound *hysteria2.Inbound + usersMap map[string]option.Hysteria2User + + mtx sync.Mutex +} + +func (i *Hysteria2UserManager) postUpdate() { + users := make([]option.Hysteria2User, 0, len(i.usersMap)) + for _, user := range i.usersMap { + users = append(users, user) + } + i.inbound.UpdateUsers(users) +} + +func (i *Hysteria2UserManager) UpdateUser(user CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + i.usersMap[user.Username] = option.Hysteria2User{Name: user.Username, Password: user.Password} + i.postUpdate() +} + +func (i *Hysteria2UserManager) UpdateUsers(users []CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.usersMap) + for _, user := range users { + i.usersMap[user.Username] = option.Hysteria2User{Name: user.Username, Password: user.Password} + } + i.postUpdate() +} + +func (i *Hysteria2UserManager) 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 new file mode 100644 index 00000000..5ccabce9 --- /dev/null +++ b/service/node/inbound/trojan.go @@ -0,0 +1,88 @@ +package inbound + +import ( + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/trojan" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" +) + +type TrojanManager struct { + access sync.Mutex + inbounds map[string]*TrojanUserManager +} + +func NewTrojanManager() *TrojanManager { + return &TrojanManager{ + inbounds: make(map[string]*TrojanUserManager), + } +} + +func (m *TrojanManager) AddUserManager(inbound adapter.Inbound) error { + m.access.Lock() + defer m.access.Unlock() + m.inbounds[inbound.Tag()] = &TrojanUserManager{ + inbound: inbound.(*trojan.Inbound), + usersMap: make(map[string]option.TrojanUser), + } + return nil +} + +func (m *TrojanManager) GetUserManager(tag string) (constant.UserManager, bool) { + m.access.Lock() + defer m.access.Unlock() + inbound, ok := m.inbounds[tag] + return inbound, ok +} + +func (m *TrojanManager) GetUserManagerTags() []string { + m.access.Lock() + defer m.access.Unlock() + tags := make([]string, 0, len(m.inbounds)) + for tag, _ := range m.inbounds { + tags = append(tags, tag) + } + return tags +} + +type TrojanUserManager struct { + inbound *trojan.Inbound + usersMap map[string]option.TrojanUser + + mtx sync.Mutex +} + +func (i *TrojanUserManager) postUpdate() { + users := make([]option.TrojanUser, 0, len(i.usersMap)) + for _, user := range i.usersMap { + users = append(users, user) + } + i.inbound.UpdateUsers(users) +} + +func (i *TrojanUserManager) UpdateUser(user CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + i.usersMap[user.Username] = option.TrojanUser{Name: user.Username, Password: user.Password} + i.postUpdate() +} + +func (i *TrojanUserManager) UpdateUsers(users []CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.usersMap) + for _, user := range users { + i.usersMap[user.Username] = option.TrojanUser{Name: user.Username, Password: user.Password} + } + i.postUpdate() +} + +func (i *TrojanUserManager) 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 new file mode 100644 index 00000000..047625b6 --- /dev/null +++ b/service/node/inbound/tuic.go @@ -0,0 +1,88 @@ +package inbound + +import ( + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/tuic" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" +) + +type TUICManager struct { + access sync.Mutex + inbounds map[string]*TUICUserManager +} + +func NewTUICManager() *TUICManager { + return &TUICManager{ + inbounds: make(map[string]*TUICUserManager), + } +} + +func (m *TUICManager) AddUserManager(inbound adapter.Inbound) error { + m.access.Lock() + defer m.access.Unlock() + m.inbounds[inbound.Tag()] = &TUICUserManager{ + inbound: inbound.(*tuic.Inbound), + usersMap: make(map[string]option.TUICUser), + } + return nil +} + +func (m *TUICManager) GetUserManager(tag string) (constant.UserManager, bool) { + m.access.Lock() + defer m.access.Unlock() + inbound, ok := m.inbounds[tag] + return inbound, ok +} + +func (m *TUICManager) GetUserManagerTags() []string { + m.access.Lock() + defer m.access.Unlock() + tags := make([]string, 0, len(m.inbounds)) + for tag, _ := range m.inbounds { + tags = append(tags, tag) + } + return tags +} + +type TUICUserManager struct { + inbound *tuic.Inbound + usersMap map[string]option.TUICUser + + mtx sync.Mutex +} + +func (i *TUICUserManager) postUpdate() { + users := make([]option.TUICUser, 0, len(i.usersMap)) + for _, user := range i.usersMap { + users = append(users, user) + } + i.inbound.UpdateUsers(users) +} + +func (i *TUICUserManager) UpdateUser(user CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + i.usersMap[user.Username] = option.TUICUser{Name: user.Username, UUID: user.UUID, Password: user.Password} + i.postUpdate() +} + +func (i *TUICUserManager) UpdateUsers(users []CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.usersMap) + for _, user := range users { + i.usersMap[user.Username] = option.TUICUser{Name: user.Username, UUID: user.UUID, Password: user.Password} + } + i.postUpdate() +} + +func (i *TUICUserManager) DeleteUser(username string) { + i.mtx.Lock() + defer i.mtx.Unlock() + delete(i.usersMap, username) + i.postUpdate() +} diff --git a/service/node/inbound/vless.go b/service/node/inbound/vless.go new file mode 100644 index 00000000..f862f03a --- /dev/null +++ b/service/node/inbound/vless.go @@ -0,0 +1,88 @@ +package inbound + +import ( + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/vless" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" +) + +type VLESSManager struct { + access sync.Mutex + inbounds map[string]*VLESSUserManager +} + +func NewVLESSManager() *VLESSManager { + return &VLESSManager{ + inbounds: make(map[string]*VLESSUserManager), + } +} + +func (m *VLESSManager) AddUserManager(inbound adapter.Inbound) error { + m.access.Lock() + defer m.access.Unlock() + m.inbounds[inbound.Tag()] = &VLESSUserManager{ + inbound: inbound.(*vless.Inbound), + usersMap: make(map[string]option.VLESSUser), + } + return nil +} + +func (m *VLESSManager) GetUserManager(tag string) (constant.UserManager, bool) { + m.access.Lock() + defer m.access.Unlock() + inbound, ok := m.inbounds[tag] + return inbound, ok +} + +func (m *VLESSManager) GetUserManagerTags() []string { + m.access.Lock() + defer m.access.Unlock() + tags := make([]string, 0, len(m.inbounds)) + for tag, _ := range m.inbounds { + tags = append(tags, tag) + } + return tags +} + +type VLESSUserManager struct { + inbound *vless.Inbound + usersMap map[string]option.VLESSUser + + mtx sync.Mutex +} + +func (i *VLESSUserManager) postUpdate() { + users := make([]option.VLESSUser, 0, len(i.usersMap)) + for _, user := range i.usersMap { + users = append(users, user) + } + i.inbound.UpdateUsers(users) +} + +func (i *VLESSUserManager) UpdateUser(user CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + i.usersMap[user.Username] = option.VLESSUser{Name: user.Username, UUID: user.UUID, Flow: user.Flow} + i.postUpdate() +} + +func (i *VLESSUserManager) UpdateUsers(users []CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.usersMap) + for _, user := range users { + i.usersMap[user.Username] = option.VLESSUser{Name: user.Username, UUID: user.UUID, Flow: user.Flow} + } + i.postUpdate() +} + +func (i *VLESSUserManager) DeleteUser(username string) { + i.mtx.Lock() + defer i.mtx.Unlock() + delete(i.usersMap, username) + i.postUpdate() +} diff --git a/service/node/inbound/vmess.go b/service/node/inbound/vmess.go new file mode 100644 index 00000000..f336f3cd --- /dev/null +++ b/service/node/inbound/vmess.go @@ -0,0 +1,88 @@ +package inbound + +import ( + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/vmess" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" +) + +type VMessManager struct { + inbounds map[string]*VMessUserManager + mtx sync.Mutex +} + +func NewVMessManager() *VMessManager { + return &VMessManager{ + inbounds: make(map[string]*VMessUserManager), + } +} + +func (m *VMessManager) AddUserManager(inbound adapter.Inbound) error { + m.mtx.Lock() + defer m.mtx.Unlock() + m.inbounds[inbound.Tag()] = &VMessUserManager{ + inbound: inbound.(*vmess.Inbound), + usersMap: make(map[string]option.VMessUser), + } + return nil +} + +func (m *VMessManager) GetUserManager(tag string) (constant.UserManager, bool) { + m.mtx.Lock() + defer m.mtx.Unlock() + inbound, ok := m.inbounds[tag] + return inbound, ok +} + +func (m *VMessManager) 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 VMessUserManager struct { + inbound *vmess.Inbound + usersMap map[string]option.VMessUser + + mtx sync.Mutex +} + +func (i *VMessUserManager) postUpdate() { + users := make([]option.VMessUser, 0, len(i.usersMap)) + for _, user := range i.usersMap { + users = append(users, user) + } + i.inbound.UpdateUsers(users) +} + +func (i *VMessUserManager) UpdateUser(user CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + i.usersMap[user.Username] = option.VMessUser{Name: user.Username, UUID: user.UUID, AlterId: user.AlterID} + i.postUpdate() +} + +func (i *VMessUserManager) UpdateUsers(users []CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.usersMap) + for _, user := range users { + i.usersMap[user.Username] = option.VMessUser{Name: user.Username, UUID: user.UUID, AlterId: user.AlterID} + } + i.postUpdate() +} + +func (i *VMessUserManager) DeleteUser(username string) { + i.mtx.Lock() + defer i.mtx.Unlock() + delete(i.usersMap, username) + i.postUpdate() +} diff --git a/service/node/limiter/bandwidth.go b/service/node/limiter/bandwidth.go new file mode 100644 index 00000000..156f5400 --- /dev/null +++ b/service/node/limiter/bandwidth.go @@ -0,0 +1,107 @@ +package limiter + +import ( + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/protocol/limiter/bandwidth" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" + E "github.com/sagernet/sing/common/exceptions" +) + +type ManagedBandwidthStrategy interface { + UpdateStrategies(strategies map[string]bandwidth.BandwidthStrategy) +} + +type BandwidthLimiterManager struct { + managers map[string]*BandwidthLimiterStrategyManager + + mtx sync.Mutex +} + +func NewBandwidthLimiterManager() *BandwidthLimiterManager { + return &BandwidthLimiterManager{ + managers: make(map[string]*BandwidthLimiterStrategyManager), + } +} + +func (m *BandwidthLimiterManager) AddBandwidthLimiterStrategyManager(outbound adapter.Outbound) error { + m.mtx.Lock() + defer m.mtx.Unlock() + limiter, ok := outbound.(*bandwidth.Outbound) + if !ok { + return E.New("invalid bandwidth limiter: ", outbound.Tag()) + } + strategy, ok := limiter.GetStrategy().(ManagedBandwidthStrategy) + if !ok { + return E.New("strategy for outbound ", outbound.Tag(), " is not manager") + } + m.managers[outbound.Tag()] = &BandwidthLimiterStrategyManager{ + strategy: strategy, + strategiesMap: make(map[string]bandwidth.BandwidthStrategy), + } + return nil +} + +func (m *BandwidthLimiterManager) GetBandwidthLimiterStrategyManager(tag string) (constant.BandwidthLimiterStrategyManager, bool) { + m.mtx.Lock() + defer m.mtx.Unlock() + manager, ok := m.managers[tag] + return manager, ok +} + +func (m *BandwidthLimiterManager) GetBandwidthLimiterStrategyManagerTags() []string { + m.mtx.Lock() + defer m.mtx.Unlock() + tags := make([]string, 0, len(m.managers)) + for tag, _ := range m.managers { + tags = append(tags, tag) + } + return tags +} + +type BandwidthLimiterStrategyManager struct { + strategy ManagedBandwidthStrategy + strategiesMap map[string]bandwidth.BandwidthStrategy + + mtx sync.Mutex +} + +func (i *BandwidthLimiterStrategyManager) postUpdate() { + i.strategy.UpdateStrategies(i.strategiesMap) +} + +func (i *BandwidthLimiterStrategyManager) UpdateBandwidthLimiter(limiter CM.BandwidthLimiter) { + i.mtx.Lock() + defer i.mtx.Unlock() + strategy, err := bandwidth.CreateStrategy(limiter.Strategy, limiter.Mode, limiter.ConnectionType, limiter.RawSpeed) + if err != nil { + return + } + i.strategiesMap[limiter.Username] = strategy + i.postUpdate() +} + +func (i *BandwidthLimiterStrategyManager) UpdateBandwidthLimiters(limiters []CM.BandwidthLimiter) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.strategiesMap) + newStrategiesMap := make(map[string]bandwidth.BandwidthStrategy) + for _, limiter := range limiters { + strategy, err := bandwidth.CreateStrategy(limiter.Strategy, limiter.Mode, limiter.ConnectionType, limiter.RawSpeed) + if err != nil { + return + } + newStrategiesMap[limiter.Username] = strategy + } + i.strategiesMap = newStrategiesMap + i.postUpdate() +} + +func (i *BandwidthLimiterStrategyManager) DeleteBandwidthLimiter(username string) { + i.mtx.Lock() + defer i.mtx.Unlock() + delete(i.strategiesMap, username) + i.postUpdate() +} diff --git a/service/node/limiter/connection.go b/service/node/limiter/connection.go new file mode 100644 index 00000000..573e7982 --- /dev/null +++ b/service/node/limiter/connection.go @@ -0,0 +1,195 @@ +package limiter + +import ( + "context" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/protocol/limiter/connection" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" + E "github.com/sagernet/sing/common/exceptions" +) + +type ManagedConnectionStrategy interface { + UpdateStrategies(strategies map[string]connection.ConnectionStrategy) +} + +type ConnectionLimiterManager struct { + nodeManager CM.NodeManager + managers map[string]*ConnectionLimiterStrategyManager + logger log.Logger + + mtx sync.Mutex +} + +func NewConnectionLimiterManager(nodeManager CM.NodeManager, logger log.Logger) *ConnectionLimiterManager { + return &ConnectionLimiterManager{ + nodeManager: nodeManager, + managers: make(map[string]*ConnectionLimiterStrategyManager), + logger: logger, + } +} + +func (m *ConnectionLimiterManager) AddConnectionLimiterStrategyManager(outbound adapter.Outbound) error { + m.mtx.Lock() + defer m.mtx.Unlock() + limiter, ok := outbound.(*connection.Outbound) + if !ok { + return E.New("invalid connection limiter: ", outbound.Tag()) + } + strategy, ok := limiter.GetStrategy().(ManagedConnectionStrategy) + if !ok { + return E.New("strategy ", strategy, " is not manager") + } + m.managers[outbound.Tag()] = &ConnectionLimiterStrategyManager{ + strategy: strategy, + strategiesMap: make(map[string]connection.ConnectionStrategy), + manager: m, + } + return nil +} + +func (m *ConnectionLimiterManager) GetConnectionLimiterStrategyManager(tag string) (constant.ConnectionLimiterStrategyManager, bool) { + m.mtx.Lock() + defer m.mtx.Unlock() + manager, ok := m.managers[tag] + return manager, ok +} + +func (m *ConnectionLimiterManager) GetConnectionLimiterStrategyManagerTags() []string { + m.mtx.Lock() + defer m.mtx.Unlock() + tags := make([]string, 0, len(m.managers)) + for tag, _ := range m.managers { + tags = append(tags, tag) + } + return tags +} + +type ConnectionLimiterStrategyManager struct { + strategy ManagedConnectionStrategy + strategiesMap map[string]connection.ConnectionStrategy + tag string + manager *ConnectionLimiterManager + + mtx sync.Mutex +} + +func (i *ConnectionLimiterStrategyManager) postUpdate() { + i.strategy.UpdateStrategies(i.strategiesMap) +} + +func (i *ConnectionLimiterStrategyManager) UpdateConnectionLimiter(limiter CM.ConnectionLimiter) { + i.mtx.Lock() + defer i.mtx.Unlock() + lock, err := i.createLock(limiter) + if err != nil { + return + } + strategy, err := connection.CreateStrategy(limiter.Strategy, limiter.ConnectionType, lock) + if err != nil { + return + } + i.strategiesMap[limiter.Username] = strategy + i.postUpdate() +} + +func (i *ConnectionLimiterStrategyManager) UpdateConnectionLimiters(limiters []CM.ConnectionLimiter) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.strategiesMap) + newStrategiesMap := make(map[string]connection.ConnectionStrategy) + for _, limiter := range limiters { + lock, err := i.createLock(limiter) + if err != nil { + return + } + strategy, err := connection.CreateStrategy(limiter.Strategy, limiter.ConnectionType, lock) + if err != nil { + return + } + newStrategiesMap[limiter.Username] = strategy + } + i.strategiesMap = newStrategiesMap + i.postUpdate() +} + +func (i *ConnectionLimiterStrategyManager) DeleteConnectionLimiter(username string) { + i.mtx.Lock() + defer i.mtx.Unlock() + delete(i.strategiesMap, username) + i.postUpdate() +} + +func (i *ConnectionLimiterStrategyManager) createLock(limiter CM.ConnectionLimiter) (connection.LockIDGetter, error) { + switch limiter.LockType { + case "manager": + return i.newManagerLock(limiter.ID), nil + case "": + return connection.NewDefaultLock(limiter.Count), nil + default: + return nil, E.New("unknown lock type \"", limiter.LockType, "\"") + } +} + +type ManagerLock struct { + handleId string + ctx context.Context + cancel context.CancelFunc + handles uint32 +} + +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) { + mtx.Lock() + defer mtx.Unlock() + conn, ok := conns[id] + if !ok { + nodeManager := i.manager.nodeManager + handleId, err := nodeManager.AcquireLock(limiterId, id) + if err != nil { + return nil, nil, err + } + ctx, cancel := context.WithCancel(context.Background()) + go func() { + for { + select { + case <-ctx.Done(): + return + case <-time.After(time.Second * 5): + err := nodeManager.RefreshLock(limiterId, id, handleId) + if err != nil { + cancel() + return + } + } + } + }() + conn = &ManagerLock{ + handleId: handleId, + ctx: ctx, + cancel: cancel, + } + 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) + } + }) + }, conn.ctx, nil + } +} diff --git a/service/node/service.go b/service/node/service.go new file mode 100644 index 00000000..e66dc674 --- /dev/null +++ b/service/node/service.go @@ -0,0 +1,235 @@ +package node + +import ( + "context" + "sync" + + "github.com/sagernet/sing-box/adapter" + boxService "github.com/sagernet/sing-box/adapter/service" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" + "github.com/sagernet/sing-box/service/node/inbound" + "github.com/sagernet/sing-box/service/node/limiter" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/service" +) + +func RegisterService(registry *boxService.Registry) { + boxService.Register[option.NodeServiceOptions](registry, C.TypeNode, NewService) +} + +type Service struct { + boxService.Adapter + ctx context.Context + logger log.ContextLogger + inboundManagers map[string]constant.InboundManager + bandwidthManager constant.BandwidthLimiterManager + connectionManager constant.ConnectionLimiterManager + options option.NodeServiceOptions + + mtx sync.Mutex +} + +func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.NodeServiceOptions) (adapter.Service, error) { + return &Service{ + Adapter: boxService.NewAdapter(C.TypeManager, tag), + ctx: ctx, + logger: logger, + options: options, + }, nil +} + +func (s *Service) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + boxManager := service.FromContext[adapter.ServiceManager](s.ctx) + serviceManager, ok := boxManager.Get(s.options.Manager) + if !ok { + return E.New("manager ", s.options.Manager, " not found") + } + nodeManager, ok := serviceManager.(CM.NodeManager) + if !ok { + return E.New("invalid ", s.options.Manager, " manager") + } + 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(), + "trojan": inbound.NewTrojanManager(), + "tuic": inbound.NewTUICManager(), + "vless": inbound.NewVLESSManager(), + "vmess": inbound.NewVMessManager(), + } + s.connectionManager = limiter.NewConnectionLimiterManager(nodeManager, s.logger) + s.bandwidthManager = limiter.NewBandwidthLimiterManager() + for _, tag := range s.options.Inbounds { + inbound, ok := inboundManager.Get(tag) + if !ok { + return E.New("inbound ", tag, " not found") + } + inboundManager, ok := s.inboundManagers[inbound.Type()] + if !ok { + return E.New("inbound manager for ", tag, " not found") + } + err := inboundManager.AddUserManager(inbound) + if err != nil { + return err + } + } + for _, limiter := range s.options.ConnectionLimiters { + outbound, ok := outboundManager.Outbound(limiter) + if !ok { + return E.New("outbound ", limiter, " not found") + } + err := s.connectionManager.AddConnectionLimiterStrategyManager(outbound) + if err != nil { + return err + } + } + for _, limiter := range s.options.BandwidthLimiters { + outbound, ok := outboundManager.Outbound(limiter) + if !ok { + return E.New("outbound ", limiter, " not found") + } + err := s.bandwidthManager.AddBandwidthLimiterStrategyManager(outbound) + if err != nil { + return err + } + } + return nodeManager.AddNode(s.options.UUID, s) +} + +func (s *Service) UpdateUser(user CM.User) { + s.mtx.Lock() + defer s.mtx.Unlock() + manager, ok := s.inboundManagers[user.Type] + if !ok { + return + } + userManager, ok := manager.GetUserManager(user.Inbound) + if !ok { + return + } + userManager.UpdateUser(user) +} + +func (s *Service) UpdateUsers(users []CM.User) { + s.mtx.Lock() + defer s.mtx.Unlock() + typedUsers := make(map[string][]CM.User) + for _, user := range users { + u, ok := typedUsers[user.Type] + if !ok { + typedUsers[user.Type] = make([]CM.User, 0) + } + typedUsers[user.Type] = append(u, user) + } + for type_, users := range typedUsers { + manager, ok := s.inboundManagers[type_] + if !ok { + continue + } + for _, user := range users { + userManager, ok := manager.GetUserManager(user.Inbound) + if !ok { + continue + } + userManager.UpdateUsers(users) + } + } +} + +func (s *Service) DeleteUser(user CM.User) { + s.mtx.Lock() + defer s.mtx.Unlock() + manager, ok := s.inboundManagers[user.Type] + if !ok { + return + } + userManager, ok := manager.GetUserManager(user.Inbound) + if !ok { + return + } + userManager.DeleteUser(user.Username) +} + +func (s *Service) UpdateConnectionLimiter(limiter CM.ConnectionLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + manager, ok := s.connectionManager.GetConnectionLimiterStrategyManager(limiter.Outbound) + if !ok { + return + } + manager.UpdateConnectionLimiter(limiter) +} + +func (s *Service) UpdateConnectionLimiters(limiters []CM.ConnectionLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + for _, limiter := range limiters { + manager, ok := s.connectionManager.GetConnectionLimiterStrategyManager(limiter.Outbound) + if !ok { + continue + } + manager.UpdateConnectionLimiters(limiters) + } +} + +func (s *Service) DeleteConnectionLimiter(limiter CM.ConnectionLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + manager, ok := s.connectionManager.GetConnectionLimiterStrategyManager(limiter.Outbound) + if !ok { + return + } + manager.DeleteConnectionLimiter(limiter.Username) +} + +func (s *Service) UpdateBandwidthLimiter(limiter CM.BandwidthLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + manager, ok := s.bandwidthManager.GetBandwidthLimiterStrategyManager(limiter.Outbound) + if !ok { + return + } + manager.UpdateBandwidthLimiter(limiter) +} + +func (s *Service) UpdateBandwidthLimiters(limiters []CM.BandwidthLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + for _, limiter := range limiters { + manager, ok := s.bandwidthManager.GetBandwidthLimiterStrategyManager(limiter.Outbound) + if !ok { + continue + } + manager.UpdateBandwidthLimiters(limiters) + } +} + +func (s *Service) DeleteBandwidthLimiter(limiter CM.BandwidthLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + manager, ok := s.bandwidthManager.GetBandwidthLimiterStrategyManager(limiter.Outbound) + if !ok { + return + } + manager.DeleteBandwidthLimiter(limiter.Username) +} + +func (s *Service) IsLocal() bool { + return true +} + +func (s *Service) IsOnline() bool { + return true +} + +func (s *Service) Close() error { + return nil +} diff --git a/service/node_manager/client/service.go b/service/node_manager/client/service.go new file mode 100644 index 00000000..7c429fa4 --- /dev/null +++ b/service/node_manager/client/service.go @@ -0,0 +1,274 @@ +package client + +import ( + "context" + "net" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + boxService "github.com/sagernet/sing-box/adapter/service" + "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" + CM "github.com/sagernet/sing-box/service/manager/constant" + pb "github.com/sagernet/sing-box/service/node_manager/manager" + "github.com/sagernet/sing/common" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "google.golang.org/grpc" + "google.golang.org/grpc/connectivity" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" +) + +func RegisterService(registry *boxService.Registry) { + boxService.Register[option.NodeManagerClientServiceOptions](registry, C.TypeNodeManagerClient, NewService) +} + +type Service struct { + boxService.Adapter + + ctx context.Context + logger log.ContextLogger + dialer N.Dialer + creds credentials.TransportCredentials + options option.NodeManagerClientServiceOptions + + conn *grpc.ClientConn + + mtx sync.Mutex +} + +func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.NodeManagerClientServiceOptions) (adapter.Service, error) { + outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain()) + if err != nil { + return nil, err + } + creds := insecure.NewCredentials() + if options.TLS != nil { + tlsConfig, err := tls.NewClient(ctx, options.Server, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + creds = &tlsCreds{tlsConfig} + } + return &Service{ + Adapter: boxService.NewAdapter(C.TypeManager, tag), + ctx: ctx, + logger: logger, + dialer: outboundDialer, + creds: creds, + options: options, + }, nil +} + +func (s *Service) AddNode(uuid string, node CM.ConnectedNode) error { + go func() { + isRetry := false + for { + if !isRetry { + select { + case <-s.ctx.Done(): + return + default: + isRetry = true + } + } else { + select { + case <-time.After(5 * time.Second): + break + case <-s.ctx.Done(): + return + } + } + conn, err := s.getConn() + if err != nil { + s.logger.Error(err) + continue + } + client := pb.NewManagerClient(conn) + stream, err := client.AddNode(s.ctx, &pb.Node{Uuid: uuid}) + if err != nil { + s.logger.Error(err) + continue + } + err = s.handler(node, stream) + if err != nil { + s.logger.Error(err) + continue + } + } + }() + return nil +} + +func (s *Service) AcquireLock(limiterId int, id string) (string, error) { + conn, err := s.getConn() + if err != nil { + return "", err + } + client := pb.NewManagerClient(conn) + lockReply, err := client.AcquireLock(s.ctx, &pb.AcquireLockRequest{LimiterId: int32(limiterId), Id: id}) + if err != nil { + return "", err + } + return lockReply.HandleId, err +} + +func (s *Service) RefreshLock(limiterId int, id string, handleId string) error { + conn, err := s.getConn() + if err != nil { + return err + } + client := pb.NewManagerClient(conn) + _, err = client.RefreshLock(s.ctx, &pb.LockData{LimiterId: int32(limiterId), Id: id, HandleId: handleId}) + return err +} + +func (s *Service) ReleaseLock(limiterId int, id string, handleId string) error { + conn, err := s.getConn() + if err != nil { + return err + } + client := pb.NewManagerClient(conn) + _, err = client.ReleaseLock(s.ctx, &pb.LockData{LimiterId: int32(limiterId), Id: id, HandleId: handleId}) + return err +} + +func (s *Service) Start(stage adapter.StartStage) error { + return nil +} + +func (s *Service) Close() error { + return nil +} + +func (s *Service) getConn() (*grpc.ClientConn, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + if s.conn != nil { + state := s.conn.GetState() + if state != connectivity.Shutdown && state != connectivity.TransientFailure { + return s.conn, nil + } + } + for { + conn, err := s.createConn() + if err != nil { + return nil, err + } + s.conn = conn + return conn, nil + } +} + +func (s *Service) createConn() (*grpc.ClientConn, error) { + conn, err := grpc.NewClient( + s.options.ServerOptions.Build().String(), + grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) { + return s.dialer.DialContext(ctx, N.NetworkTCP, M.ParseSocksaddr(addr)) + }), + grpc.WithTransportCredentials(s.creds), + ) + if err != nil { + return nil, err + } + return conn, nil +} + +func (s *Service) handler(node CM.ConnectedNode, stream grpc.ServerStreamingClient[pb.NodeData]) error { + for { + data, err := stream.Recv() + if err != nil { + return err + } + switch data.Op { + case pb.OpType_updateUser: + s.logger.DebugContext(s.ctx, "update user") + node.UpdateUser(s.convertUser(data.Data.(*pb.NodeData_User).User)) + case pb.OpType_updateUsers: + s.logger.DebugContext(s.ctx, "update users") + users := data.Data.(*pb.NodeData_Users).Users.Values + convertedUsers := make([]CM.User, len(users)) + for i, user := range users { + convertedUsers[i] = s.convertUser(user) + } + node.UpdateUsers(convertedUsers) + case pb.OpType_deleteUser: + s.logger.DebugContext(s.ctx, "delete user") + node.DeleteUser(s.convertUser(data.Data.(*pb.NodeData_User).User)) + + case pb.OpType_updateConnectionLimiter: + s.logger.DebugContext(s.ctx, "update connection limiter") + node.UpdateConnectionLimiter(s.convertConnectionLimiter(data.Data.(*pb.NodeData_ConnectionLimiter).ConnectionLimiter)) + case pb.OpType_updateConnectionLimiters: + s.logger.DebugContext(s.ctx, "update connection limiters") + limiters := data.Data.(*pb.NodeData_ConnectionLimiters).ConnectionLimiters.Values + convertedLimiters := make([]CM.ConnectionLimiter, len(limiters)) + for i, limiter := range limiters { + convertedLimiters[i] = s.convertConnectionLimiter(limiter) + } + node.UpdateConnectionLimiters(convertedLimiters) + case pb.OpType_deleteConnectionLimiter: + s.logger.DebugContext(s.ctx, "delete connection limiter") + node.DeleteConnectionLimiter(s.convertConnectionLimiter(data.Data.(*pb.NodeData_ConnectionLimiter).ConnectionLimiter)) + + case pb.OpType_updateBandwidthLimiter: + s.logger.DebugContext(s.ctx, "update bandwidth limiter") + node.UpdateBandwidthLimiter(s.convertBandwidthLimiter(data.Data.(*pb.NodeData_BandwidthLimiter).BandwidthLimiter)) + case pb.OpType_updateBandwidthLimiters: + s.logger.DebugContext(s.ctx, "update bandwidth limiters") + limiters := data.Data.(*pb.NodeData_BandwidthLimiters).BandwidthLimiters.Values + convertedLimiters := make([]CM.BandwidthLimiter, len(limiters)) + for i, limiter := range limiters { + convertedLimiters[i] = s.convertBandwidthLimiter(limiter) + } + node.UpdateBandwidthLimiters(convertedLimiters) + case pb.OpType_deleteBandwidthLimiter: + s.logger.DebugContext(s.ctx, "delete bandwidth limiter") + node.DeleteBandwidthLimiter(s.convertBandwidthLimiter(data.Data.(*pb.NodeData_BandwidthLimiter).BandwidthLimiter)) + } + } +} + +func (s *Service) convertUser(user *pb.User) CM.User { + return CM.User{ + ID: int(user.Id), + Username: user.Username, + Type: user.Type, + Inbound: user.Inbound, + UUID: user.Uuid, + Password: user.Password, + Flow: user.Flow, + AlterID: int(user.AlterId), + } +} + +func (s *Service) convertBandwidthLimiter(limiter *pb.BandwidthLimiter) CM.BandwidthLimiter { + return CM.BandwidthLimiter{ + ID: int(limiter.Id), + Username: limiter.Username, + Outbound: limiter.Outbound, + Strategy: limiter.Strategy, + Mode: limiter.Mode, + ConnectionType: limiter.ConnectionType, + Speed: limiter.Speed, + RawSpeed: limiter.RawSpeed, + } +} + +func (s *Service) convertConnectionLimiter(limiter *pb.ConnectionLimiter) CM.ConnectionLimiter { + return CM.ConnectionLimiter{ + ID: int(limiter.Id), + Username: limiter.Username, + Outbound: limiter.Outbound, + Strategy: limiter.Strategy, + ConnectionType: limiter.ConnectionType, + LockType: limiter.LockType, + Count: limiter.Count, + } +} diff --git a/service/node_manager/client/tls.go b/service/node_manager/client/tls.go new file mode 100644 index 00000000..d2ce4baa --- /dev/null +++ b/service/node_manager/client/tls.go @@ -0,0 +1,44 @@ +package client + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/common/tls" + E "github.com/sagernet/sing/common/exceptions" + "google.golang.org/grpc/credentials" +) + +type tlsCreds struct { + // TLS configuration + config tls.Config +} + +func (c tlsCreds) Info() credentials.ProtocolInfo { + return credentials.ProtocolInfo{ + SecurityProtocol: "tls", + SecurityVersion: "1.2", + ServerName: c.config.ServerName(), + } +} + +func (c *tlsCreds) ClientHandshake(ctx context.Context, authority string, rawConn net.Conn) (net.Conn, credentials.AuthInfo, error) { + conn, err := tls.ClientHandshake(ctx, rawConn, c.config) + if err != nil { + return nil, nil, err + } + return conn, credentials.TLSInfo{State: conn.ConnectionState()}, err +} + +func (c *tlsCreds) ServerHandshake(rawConn net.Conn) (net.Conn, credentials.AuthInfo, error) { + return nil, nil, E.New("not implemented") +} + +func (c *tlsCreds) Clone() credentials.TransportCredentials { + return &tlsCreds{config: c.config.Clone()} +} + +func (c *tlsCreds) OverrideServerName(serverNameOverride string) error { + c.config.SetServerName(serverNameOverride) + return nil +} diff --git a/service/node_manager/manager/manager.pb.go b/service/node_manager/manager/manager.pb.go new file mode 100644 index 00000000..b8985a80 --- /dev/null +++ b/service/node_manager/manager/manager.pb.go @@ -0,0 +1,1023 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v6.33.1 +// source: manager/manager.proto + +package manager + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type OpType int32 + +const ( + OpType_updateUsers OpType = 0 + OpType_updateUser OpType = 1 + OpType_deleteUser OpType = 2 + OpType_updateBandwidthLimiters OpType = 3 + OpType_updateBandwidthLimiter OpType = 4 + OpType_deleteBandwidthLimiter OpType = 5 + OpType_updateConnectionLimiters OpType = 6 + OpType_updateConnectionLimiter OpType = 7 + OpType_deleteConnectionLimiter OpType = 8 +) + +// Enum value maps for OpType. +var ( + OpType_name = map[int32]string{ + 0: "updateUsers", + 1: "updateUser", + 2: "deleteUser", + 3: "updateBandwidthLimiters", + 4: "updateBandwidthLimiter", + 5: "deleteBandwidthLimiter", + 6: "updateConnectionLimiters", + 7: "updateConnectionLimiter", + 8: "deleteConnectionLimiter", + } + OpType_value = map[string]int32{ + "updateUsers": 0, + "updateUser": 1, + "deleteUser": 2, + "updateBandwidthLimiters": 3, + "updateBandwidthLimiter": 4, + "deleteBandwidthLimiter": 5, + "updateConnectionLimiters": 6, + "updateConnectionLimiter": 7, + "deleteConnectionLimiter": 8, + } +) + +func (x OpType) Enum() *OpType { + p := new(OpType) + *p = x + return p +} + +func (x OpType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (OpType) Descriptor() protoreflect.EnumDescriptor { + return file_manager_manager_proto_enumTypes[0].Descriptor() +} + +func (OpType) Type() protoreflect.EnumType { + return &file_manager_manager_proto_enumTypes[0] +} + +func (x OpType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use OpType.Descriptor instead. +func (OpType) EnumDescriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{0} +} + +type Node struct { + state protoimpl.MessageState `protogen:"open.v1"` + Uuid string `protobuf:"bytes,1,opt,name=uuid,proto3" json:"uuid,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Node) Reset() { + *x = Node{} + mi := &file_manager_manager_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Node) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Node) ProtoMessage() {} + +func (x *Node) ProtoReflect() protoreflect.Message { + mi := &file_manager_manager_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Node.ProtoReflect.Descriptor instead. +func (*Node) Descriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{0} +} + +func (x *Node) GetUuid() string { + if x != nil { + return x.Uuid + } + return "" +} + +type User struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` + Type string `protobuf:"bytes,4,opt,name=type,proto3" json:"type,omitempty"` + Inbound string `protobuf:"bytes,5,opt,name=inbound,proto3" json:"inbound,omitempty"` + Uuid string `protobuf:"bytes,6,opt,name=uuid,proto3" json:"uuid,omitempty"` + Password string `protobuf:"bytes,7,opt,name=password,proto3" json:"password,omitempty"` + Flow string `protobuf:"bytes,8,opt,name=flow,proto3" json:"flow,omitempty"` + AlterId int32 `protobuf:"varint,9,opt,name=alter_id,json=alterId,proto3" json:"alter_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *User) Reset() { + *x = User{} + mi := &file_manager_manager_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *User) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*User) ProtoMessage() {} + +func (x *User) ProtoReflect() protoreflect.Message { + mi := &file_manager_manager_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use User.ProtoReflect.Descriptor instead. +func (*User) Descriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{1} +} + +func (x *User) GetId() int32 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *User) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *User) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *User) GetInbound() string { + if x != nil { + return x.Inbound + } + return "" +} + +func (x *User) GetUuid() string { + if x != nil { + return x.Uuid + } + return "" +} + +func (x *User) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +func (x *User) GetFlow() string { + if x != nil { + return x.Flow + } + return "" +} + +func (x *User) GetAlterId() int32 { + if x != nil { + return x.AlterId + } + return 0 +} + +type UserList struct { + state protoimpl.MessageState `protogen:"open.v1"` + Values []*User `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserList) Reset() { + *x = UserList{} + mi := &file_manager_manager_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserList) ProtoMessage() {} + +func (x *UserList) ProtoReflect() protoreflect.Message { + mi := &file_manager_manager_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserList.ProtoReflect.Descriptor instead. +func (*UserList) Descriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{2} +} + +func (x *UserList) GetValues() []*User { + if x != nil { + return x.Values + } + return nil +} + +type BandwidthLimiter struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` + Outbound string `protobuf:"bytes,4,opt,name=outbound,proto3" json:"outbound,omitempty"` + Strategy string `protobuf:"bytes,5,opt,name=strategy,proto3" json:"strategy,omitempty"` + Mode string `protobuf:"bytes,6,opt,name=mode,proto3" json:"mode,omitempty"` + ConnectionType string `protobuf:"bytes,7,opt,name=connection_type,json=connectionType,proto3" json:"connection_type,omitempty"` + Speed string `protobuf:"bytes,8,opt,name=speed,proto3" json:"speed,omitempty"` + RawSpeed uint64 `protobuf:"varint,9,opt,name=raw_speed,json=rawSpeed,proto3" json:"raw_speed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BandwidthLimiter) Reset() { + *x = BandwidthLimiter{} + mi := &file_manager_manager_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BandwidthLimiter) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BandwidthLimiter) ProtoMessage() {} + +func (x *BandwidthLimiter) ProtoReflect() protoreflect.Message { + mi := &file_manager_manager_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BandwidthLimiter.ProtoReflect.Descriptor instead. +func (*BandwidthLimiter) Descriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{3} +} + +func (x *BandwidthLimiter) GetId() int32 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *BandwidthLimiter) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *BandwidthLimiter) GetOutbound() string { + if x != nil { + return x.Outbound + } + return "" +} + +func (x *BandwidthLimiter) GetStrategy() string { + if x != nil { + return x.Strategy + } + return "" +} + +func (x *BandwidthLimiter) GetMode() string { + if x != nil { + return x.Mode + } + return "" +} + +func (x *BandwidthLimiter) GetConnectionType() string { + if x != nil { + return x.ConnectionType + } + return "" +} + +func (x *BandwidthLimiter) GetSpeed() string { + if x != nil { + return x.Speed + } + return "" +} + +func (x *BandwidthLimiter) GetRawSpeed() uint64 { + if x != nil { + return x.RawSpeed + } + return 0 +} + +type BandwidthLimiterList struct { + state protoimpl.MessageState `protogen:"open.v1"` + Values []*BandwidthLimiter `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BandwidthLimiterList) Reset() { + *x = BandwidthLimiterList{} + mi := &file_manager_manager_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BandwidthLimiterList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BandwidthLimiterList) ProtoMessage() {} + +func (x *BandwidthLimiterList) ProtoReflect() protoreflect.Message { + mi := &file_manager_manager_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BandwidthLimiterList.ProtoReflect.Descriptor instead. +func (*BandwidthLimiterList) Descriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{4} +} + +func (x *BandwidthLimiterList) GetValues() []*BandwidthLimiter { + if x != nil { + return x.Values + } + return nil +} + +type ConnectionLimiter struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` + Outbound string `protobuf:"bytes,4,opt,name=outbound,proto3" json:"outbound,omitempty"` + Strategy string `protobuf:"bytes,5,opt,name=strategy,proto3" json:"strategy,omitempty"` + ConnectionType string `protobuf:"bytes,6,opt,name=connection_type,json=connectionType,proto3" json:"connection_type,omitempty"` + LockType string `protobuf:"bytes,7,opt,name=lock_type,json=lockType,proto3" json:"lock_type,omitempty"` + Count uint32 `protobuf:"varint,8,opt,name=count,proto3" json:"count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectionLimiter) Reset() { + *x = ConnectionLimiter{} + mi := &file_manager_manager_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectionLimiter) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectionLimiter) ProtoMessage() {} + +func (x *ConnectionLimiter) ProtoReflect() protoreflect.Message { + mi := &file_manager_manager_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectionLimiter.ProtoReflect.Descriptor instead. +func (*ConnectionLimiter) Descriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{5} +} + +func (x *ConnectionLimiter) GetId() int32 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *ConnectionLimiter) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *ConnectionLimiter) GetOutbound() string { + if x != nil { + return x.Outbound + } + return "" +} + +func (x *ConnectionLimiter) GetStrategy() string { + if x != nil { + return x.Strategy + } + return "" +} + +func (x *ConnectionLimiter) GetConnectionType() string { + if x != nil { + return x.ConnectionType + } + return "" +} + +func (x *ConnectionLimiter) GetLockType() string { + if x != nil { + return x.LockType + } + return "" +} + +func (x *ConnectionLimiter) GetCount() uint32 { + if x != nil { + return x.Count + } + return 0 +} + +type ConnectionLimiterList struct { + state protoimpl.MessageState `protogen:"open.v1"` + Values []*ConnectionLimiter `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectionLimiterList) Reset() { + *x = ConnectionLimiterList{} + mi := &file_manager_manager_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectionLimiterList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectionLimiterList) ProtoMessage() {} + +func (x *ConnectionLimiterList) ProtoReflect() protoreflect.Message { + mi := &file_manager_manager_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectionLimiterList.ProtoReflect.Descriptor instead. +func (*ConnectionLimiterList) Descriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{6} +} + +func (x *ConnectionLimiterList) GetValues() []*ConnectionLimiter { + if x != nil { + return x.Values + } + return nil +} + +type NodeData struct { + state protoimpl.MessageState `protogen:"open.v1"` + Op OpType `protobuf:"varint,1,opt,name=op,proto3,enum=manager.v1.OpType" json:"op,omitempty"` + // Types that are valid to be assigned to Data: + // + // *NodeData_Users + // *NodeData_User + // *NodeData_BandwidthLimiters + // *NodeData_BandwidthLimiter + // *NodeData_ConnectionLimiters + // *NodeData_ConnectionLimiter + Data isNodeData_Data `protobuf_oneof:"data"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NodeData) Reset() { + *x = NodeData{} + mi := &file_manager_manager_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NodeData) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NodeData) ProtoMessage() {} + +func (x *NodeData) ProtoReflect() protoreflect.Message { + mi := &file_manager_manager_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NodeData.ProtoReflect.Descriptor instead. +func (*NodeData) Descriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{7} +} + +func (x *NodeData) GetOp() OpType { + if x != nil { + return x.Op + } + return OpType_updateUsers +} + +func (x *NodeData) GetData() isNodeData_Data { + if x != nil { + return x.Data + } + return nil +} + +func (x *NodeData) GetUsers() *UserList { + if x != nil { + if x, ok := x.Data.(*NodeData_Users); ok { + return x.Users + } + } + return nil +} + +func (x *NodeData) GetUser() *User { + if x != nil { + if x, ok := x.Data.(*NodeData_User); ok { + return x.User + } + } + return nil +} + +func (x *NodeData) GetBandwidthLimiters() *BandwidthLimiterList { + if x != nil { + if x, ok := x.Data.(*NodeData_BandwidthLimiters); ok { + return x.BandwidthLimiters + } + } + return nil +} + +func (x *NodeData) GetBandwidthLimiter() *BandwidthLimiter { + if x != nil { + if x, ok := x.Data.(*NodeData_BandwidthLimiter); ok { + return x.BandwidthLimiter + } + } + return nil +} + +func (x *NodeData) GetConnectionLimiters() *ConnectionLimiterList { + if x != nil { + if x, ok := x.Data.(*NodeData_ConnectionLimiters); ok { + return x.ConnectionLimiters + } + } + return nil +} + +func (x *NodeData) GetConnectionLimiter() *ConnectionLimiter { + if x != nil { + if x, ok := x.Data.(*NodeData_ConnectionLimiter); ok { + return x.ConnectionLimiter + } + } + return nil +} + +type isNodeData_Data interface { + isNodeData_Data() +} + +type NodeData_Users struct { + Users *UserList `protobuf:"bytes,2,opt,name=users,proto3,oneof"` +} + +type NodeData_User struct { + User *User `protobuf:"bytes,3,opt,name=user,proto3,oneof"` +} + +type NodeData_BandwidthLimiters struct { + BandwidthLimiters *BandwidthLimiterList `protobuf:"bytes,4,opt,name=bandwidth_limiters,json=bandwidthLimiters,proto3,oneof"` +} + +type NodeData_BandwidthLimiter struct { + BandwidthLimiter *BandwidthLimiter `protobuf:"bytes,5,opt,name=bandwidth_limiter,json=bandwidthLimiter,proto3,oneof"` +} + +type NodeData_ConnectionLimiters struct { + ConnectionLimiters *ConnectionLimiterList `protobuf:"bytes,6,opt,name=connection_limiters,json=connectionLimiters,proto3,oneof"` +} + +type NodeData_ConnectionLimiter struct { + ConnectionLimiter *ConnectionLimiter `protobuf:"bytes,7,opt,name=connection_limiter,json=connectionLimiter,proto3,oneof"` +} + +func (*NodeData_Users) isNodeData_Data() {} + +func (*NodeData_User) isNodeData_Data() {} + +func (*NodeData_BandwidthLimiters) isNodeData_Data() {} + +func (*NodeData_BandwidthLimiter) isNodeData_Data() {} + +func (*NodeData_ConnectionLimiters) isNodeData_Data() {} + +func (*NodeData_ConnectionLimiter) isNodeData_Data() {} + +type AcquireLockRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + LimiterId int32 `protobuf:"varint,1,opt,name=limiter_id,json=limiterId,proto3" json:"limiter_id,omitempty"` + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AcquireLockRequest) Reset() { + *x = AcquireLockRequest{} + mi := &file_manager_manager_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AcquireLockRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AcquireLockRequest) ProtoMessage() {} + +func (x *AcquireLockRequest) ProtoReflect() protoreflect.Message { + mi := &file_manager_manager_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AcquireLockRequest.ProtoReflect.Descriptor instead. +func (*AcquireLockRequest) Descriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{8} +} + +func (x *AcquireLockRequest) GetLimiterId() int32 { + if x != nil { + return x.LimiterId + } + return 0 +} + +func (x *AcquireLockRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type LockData struct { + state protoimpl.MessageState `protogen:"open.v1"` + LimiterId int32 `protobuf:"varint,1,opt,name=limiter_id,json=limiterId,proto3" json:"limiter_id,omitempty"` + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` + HandleId string `protobuf:"bytes,3,opt,name=handleId,proto3" json:"handleId,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LockData) Reset() { + *x = LockData{} + mi := &file_manager_manager_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LockData) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LockData) ProtoMessage() {} + +func (x *LockData) ProtoReflect() protoreflect.Message { + mi := &file_manager_manager_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LockData.ProtoReflect.Descriptor instead. +func (*LockData) Descriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{9} +} + +func (x *LockData) GetLimiterId() int32 { + if x != nil { + return x.LimiterId + } + return 0 +} + +func (x *LockData) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *LockData) GetHandleId() string { + if x != nil { + return x.HandleId + } + return "" +} + +type Empty struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Empty) Reset() { + *x = Empty{} + mi := &file_manager_manager_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Empty) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Empty) ProtoMessage() {} + +func (x *Empty) ProtoReflect() protoreflect.Message { + mi := &file_manager_manager_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Empty.ProtoReflect.Descriptor instead. +func (*Empty) Descriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{10} +} + +var File_manager_manager_proto protoreflect.FileDescriptor + +const file_manager_manager_proto_rawDesc = "" + + "\n" + + "\x15manager/manager.proto\x12\n" + + "manager.v1\"\x1a\n" + + "\x04Node\x12\x12\n" + + "\x04uuid\x18\x01 \x01(\tR\x04uuid\"\xbf\x01\n" + + "\x04User\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x05R\x02id\x12\x1a\n" + + "\busername\x18\x03 \x01(\tR\busername\x12\x12\n" + + "\x04type\x18\x04 \x01(\tR\x04type\x12\x18\n" + + "\ainbound\x18\x05 \x01(\tR\ainbound\x12\x12\n" + + "\x04uuid\x18\x06 \x01(\tR\x04uuid\x12\x1a\n" + + "\bpassword\x18\a \x01(\tR\bpassword\x12\x12\n" + + "\x04flow\x18\b \x01(\tR\x04flow\x12\x19\n" + + "\balter_id\x18\t \x01(\x05R\aalterId\"4\n" + + "\bUserList\x12(\n" + + "\x06values\x18\x01 \x03(\v2\x10.manager.v1.UserR\x06values\"\xe6\x01\n" + + "\x10BandwidthLimiter\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x05R\x02id\x12\x1a\n" + + "\busername\x18\x03 \x01(\tR\busername\x12\x1a\n" + + "\boutbound\x18\x04 \x01(\tR\boutbound\x12\x1a\n" + + "\bstrategy\x18\x05 \x01(\tR\bstrategy\x12\x12\n" + + "\x04mode\x18\x06 \x01(\tR\x04mode\x12'\n" + + "\x0fconnection_type\x18\a \x01(\tR\x0econnectionType\x12\x14\n" + + "\x05speed\x18\b \x01(\tR\x05speed\x12\x1b\n" + + "\traw_speed\x18\t \x01(\x04R\brawSpeed\"L\n" + + "\x14BandwidthLimiterList\x124\n" + + "\x06values\x18\x01 \x03(\v2\x1c.manager.v1.BandwidthLimiterR\x06values\"\xd3\x01\n" + + "\x11ConnectionLimiter\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x05R\x02id\x12\x1a\n" + + "\busername\x18\x03 \x01(\tR\busername\x12\x1a\n" + + "\boutbound\x18\x04 \x01(\tR\boutbound\x12\x1a\n" + + "\bstrategy\x18\x05 \x01(\tR\bstrategy\x12'\n" + + "\x0fconnection_type\x18\x06 \x01(\tR\x0econnectionType\x12\x1b\n" + + "\tlock_type\x18\a \x01(\tR\blockType\x12\x14\n" + + "\x05count\x18\b \x01(\rR\x05count\"N\n" + + "\x15ConnectionLimiterList\x125\n" + + "\x06values\x18\x01 \x03(\v2\x1d.manager.v1.ConnectionLimiterR\x06values\"\xd2\x03\n" + + "\bNodeData\x12\"\n" + + "\x02op\x18\x01 \x01(\x0e2\x12.manager.v1.OpTypeR\x02op\x12,\n" + + "\x05users\x18\x02 \x01(\v2\x14.manager.v1.UserListH\x00R\x05users\x12&\n" + + "\x04user\x18\x03 \x01(\v2\x10.manager.v1.UserH\x00R\x04user\x12Q\n" + + "\x12bandwidth_limiters\x18\x04 \x01(\v2 .manager.v1.BandwidthLimiterListH\x00R\x11bandwidthLimiters\x12K\n" + + "\x11bandwidth_limiter\x18\x05 \x01(\v2\x1c.manager.v1.BandwidthLimiterH\x00R\x10bandwidthLimiter\x12T\n" + + "\x13connection_limiters\x18\x06 \x01(\v2!.manager.v1.ConnectionLimiterListH\x00R\x12connectionLimiters\x12N\n" + + "\x12connection_limiter\x18\a \x01(\v2\x1d.manager.v1.ConnectionLimiterH\x00R\x11connectionLimiterB\x06\n" + + "\x04data\"C\n" + + "\x12AcquireLockRequest\x12\x1d\n" + + "\n" + + "limiter_id\x18\x01 \x01(\x05R\tlimiterId\x12\x0e\n" + + "\x02id\x18\x02 \x01(\tR\x02id\"U\n" + + "\bLockData\x12\x1d\n" + + "\n" + + "limiter_id\x18\x01 \x01(\x05R\tlimiterId\x12\x0e\n" + + "\x02id\x18\x02 \x01(\tR\x02id\x12\x1a\n" + + "\bhandleId\x18\x03 \x01(\tR\bhandleId\"\a\n" + + "\x05Empty*\xe6\x01\n" + + "\x06OpType\x12\x0f\n" + + "\vupdateUsers\x10\x00\x12\x0e\n" + + "\n" + + "updateUser\x10\x01\x12\x0e\n" + + "\n" + + "deleteUser\x10\x02\x12\x1b\n" + + "\x17updateBandwidthLimiters\x10\x03\x12\x1a\n" + + "\x16updateBandwidthLimiter\x10\x04\x12\x1a\n" + + "\x16deleteBandwidthLimiter\x10\x05\x12\x1c\n" + + "\x18updateConnectionLimiters\x10\x06\x12\x1b\n" + + "\x17updateConnectionLimiter\x10\a\x12\x1b\n" + + "\x17deleteConnectionLimiter\x10\b2\xf3\x01\n" + + "\aManager\x123\n" + + "\aAddNode\x12\x10.manager.v1.Node\x1a\x14.manager.v1.NodeData0\x01\x12C\n" + + "\vAcquireLock\x12\x1e.manager.v1.AcquireLockRequest\x1a\x14.manager.v1.LockData\x126\n" + + "\vRefreshLock\x12\x14.manager.v1.LockData\x1a\x11.manager.v1.Empty\x126\n" + + "\vReleaseLock\x12\x14.manager.v1.LockData\x1a\x11.manager.v1.EmptyB manager.v1.User + 4, // 1: manager.v1.BandwidthLimiterList.values:type_name -> manager.v1.BandwidthLimiter + 6, // 2: manager.v1.ConnectionLimiterList.values:type_name -> manager.v1.ConnectionLimiter + 0, // 3: manager.v1.NodeData.op:type_name -> manager.v1.OpType + 3, // 4: manager.v1.NodeData.users:type_name -> manager.v1.UserList + 2, // 5: manager.v1.NodeData.user:type_name -> manager.v1.User + 5, // 6: manager.v1.NodeData.bandwidth_limiters:type_name -> manager.v1.BandwidthLimiterList + 4, // 7: manager.v1.NodeData.bandwidth_limiter:type_name -> manager.v1.BandwidthLimiter + 7, // 8: manager.v1.NodeData.connection_limiters:type_name -> manager.v1.ConnectionLimiterList + 6, // 9: manager.v1.NodeData.connection_limiter:type_name -> manager.v1.ConnectionLimiter + 1, // 10: manager.v1.Manager.AddNode:input_type -> manager.v1.Node + 9, // 11: manager.v1.Manager.AcquireLock:input_type -> manager.v1.AcquireLockRequest + 10, // 12: manager.v1.Manager.RefreshLock:input_type -> manager.v1.LockData + 10, // 13: manager.v1.Manager.ReleaseLock:input_type -> manager.v1.LockData + 8, // 14: manager.v1.Manager.AddNode:output_type -> manager.v1.NodeData + 10, // 15: manager.v1.Manager.AcquireLock:output_type -> manager.v1.LockData + 11, // 16: manager.v1.Manager.RefreshLock:output_type -> manager.v1.Empty + 11, // 17: manager.v1.Manager.ReleaseLock:output_type -> manager.v1.Empty + 14, // [14:18] is the sub-list for method output_type + 10, // [10:14] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name +} + +func init() { file_manager_manager_proto_init() } +func file_manager_manager_proto_init() { + if File_manager_manager_proto != nil { + return + } + file_manager_manager_proto_msgTypes[7].OneofWrappers = []any{ + (*NodeData_Users)(nil), + (*NodeData_User)(nil), + (*NodeData_BandwidthLimiters)(nil), + (*NodeData_BandwidthLimiter)(nil), + (*NodeData_ConnectionLimiters)(nil), + (*NodeData_ConnectionLimiter)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_manager_manager_proto_rawDesc), len(file_manager_manager_proto_rawDesc)), + NumEnums: 1, + NumMessages: 11, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_manager_manager_proto_goTypes, + DependencyIndexes: file_manager_manager_proto_depIdxs, + EnumInfos: file_manager_manager_proto_enumTypes, + MessageInfos: file_manager_manager_proto_msgTypes, + }.Build() + File_manager_manager_proto = out.File + file_manager_manager_proto_goTypes = nil + file_manager_manager_proto_depIdxs = nil +} diff --git a/service/node_manager/manager/manager.proto b/service/node_manager/manager/manager.proto new file mode 100644 index 00000000..d59f4dd6 --- /dev/null +++ b/service/node_manager/manager/manager.proto @@ -0,0 +1,104 @@ +syntax = "proto3"; + +option go_package = "github.com/sagernet/sing-box/service/remotemanager/manager"; + +package manager.v1; + +service Manager { + rpc AddNode (Node) returns (stream NodeData); + rpc AcquireLock(AcquireLockRequest) returns (LockData); + rpc RefreshLock(LockData) returns (Empty); + rpc ReleaseLock(LockData) returns (Empty); +} + +message Node { + string uuid = 1; +} + +enum OpType { + updateUsers = 0; + updateUser = 1; + deleteUser = 2; + + updateBandwidthLimiters = 3; + updateBandwidthLimiter = 4; + deleteBandwidthLimiter = 5; + + updateConnectionLimiters = 6; + updateConnectionLimiter = 7; + deleteConnectionLimiter = 8; +} + +message User { + int32 id = 1; + string username = 3; + string type = 4; + string inbound = 5; + string uuid = 6; + string password = 7; + string flow = 8; + int32 alter_id = 9; +} + +message UserList { + repeated User values = 1; +} + +message BandwidthLimiter { + int32 id = 1; + string username = 3; + string outbound = 4; + string strategy = 5; + string mode = 6; + string connection_type = 7; + string speed = 8; + uint64 raw_speed = 9; +} + +message BandwidthLimiterList { + repeated BandwidthLimiter values = 1; +} + +message ConnectionLimiter { + int32 id = 1; + string username = 3; + string outbound = 4; + string strategy = 5; + string connection_type = 6; + string lock_type = 7; + uint32 count = 8; +} + +message ConnectionLimiterList { + repeated ConnectionLimiter values = 1; +} + +message NodeData { + + OpType op = 1; + + oneof data { + UserList users = 2; + User user = 3; + BandwidthLimiterList bandwidth_limiters = 4; + BandwidthLimiter bandwidth_limiter = 5; + ConnectionLimiterList connection_limiters = 6; + ConnectionLimiter connection_limiter = 7; + } + +} + +message AcquireLockRequest { + int32 limiter_id = 1; + string id = 2; +} + +message LockData { + int32 limiter_id = 1; + string id = 2; + string handleId = 3; +} + +message Empty { + +} diff --git a/service/node_manager/manager/manager_grpc.pb.go b/service/node_manager/manager/manager_grpc.pb.go new file mode 100644 index 00000000..f59acccf --- /dev/null +++ b/service/node_manager/manager/manager_grpc.pb.go @@ -0,0 +1,239 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.0 +// - protoc v6.33.1 +// source: manager/manager.proto + +package manager + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + Manager_AddNode_FullMethodName = "/manager.v1.Manager/AddNode" + Manager_AcquireLock_FullMethodName = "/manager.v1.Manager/AcquireLock" + Manager_RefreshLock_FullMethodName = "/manager.v1.Manager/RefreshLock" + Manager_ReleaseLock_FullMethodName = "/manager.v1.Manager/ReleaseLock" +) + +// ManagerClient is the client API for Manager service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type ManagerClient interface { + AddNode(ctx context.Context, in *Node, opts ...grpc.CallOption) (grpc.ServerStreamingClient[NodeData], error) + AcquireLock(ctx context.Context, in *AcquireLockRequest, opts ...grpc.CallOption) (*LockData, error) + RefreshLock(ctx context.Context, in *LockData, opts ...grpc.CallOption) (*Empty, error) + ReleaseLock(ctx context.Context, in *LockData, opts ...grpc.CallOption) (*Empty, error) +} + +type managerClient struct { + cc grpc.ClientConnInterface +} + +func NewManagerClient(cc grpc.ClientConnInterface) ManagerClient { + return &managerClient{cc} +} + +func (c *managerClient) AddNode(ctx context.Context, in *Node, opts ...grpc.CallOption) (grpc.ServerStreamingClient[NodeData], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &Manager_ServiceDesc.Streams[0], Manager_AddNode_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[Node, NodeData]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Manager_AddNodeClient = grpc.ServerStreamingClient[NodeData] + +func (c *managerClient) AcquireLock(ctx context.Context, in *AcquireLockRequest, opts ...grpc.CallOption) (*LockData, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(LockData) + err := c.cc.Invoke(ctx, Manager_AcquireLock_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *managerClient) RefreshLock(ctx context.Context, in *LockData, opts ...grpc.CallOption) (*Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Empty) + err := c.cc.Invoke(ctx, Manager_RefreshLock_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *managerClient) ReleaseLock(ctx context.Context, in *LockData, opts ...grpc.CallOption) (*Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Empty) + err := c.cc.Invoke(ctx, Manager_ReleaseLock_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ManagerServer is the server API for Manager service. +// All implementations must embed UnimplementedManagerServer +// for forward compatibility. +type ManagerServer interface { + AddNode(*Node, grpc.ServerStreamingServer[NodeData]) error + AcquireLock(context.Context, *AcquireLockRequest) (*LockData, error) + RefreshLock(context.Context, *LockData) (*Empty, error) + ReleaseLock(context.Context, *LockData) (*Empty, error) + mustEmbedUnimplementedManagerServer() +} + +// UnimplementedManagerServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedManagerServer struct{} + +func (UnimplementedManagerServer) AddNode(*Node, grpc.ServerStreamingServer[NodeData]) error { + return status.Error(codes.Unimplemented, "method AddNode not implemented") +} +func (UnimplementedManagerServer) AcquireLock(context.Context, *AcquireLockRequest) (*LockData, error) { + return nil, status.Error(codes.Unimplemented, "method AcquireLock not implemented") +} +func (UnimplementedManagerServer) RefreshLock(context.Context, *LockData) (*Empty, error) { + return nil, status.Error(codes.Unimplemented, "method RefreshLock not implemented") +} +func (UnimplementedManagerServer) ReleaseLock(context.Context, *LockData) (*Empty, error) { + return nil, status.Error(codes.Unimplemented, "method ReleaseLock not implemented") +} +func (UnimplementedManagerServer) mustEmbedUnimplementedManagerServer() {} +func (UnimplementedManagerServer) testEmbeddedByValue() {} + +// UnsafeManagerServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ManagerServer will +// result in compilation errors. +type UnsafeManagerServer interface { + mustEmbedUnimplementedManagerServer() +} + +func RegisterManagerServer(s grpc.ServiceRegistrar, srv ManagerServer) { + // If the following call panics, it indicates UnimplementedManagerServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&Manager_ServiceDesc, srv) +} + +func _Manager_AddNode_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(Node) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(ManagerServer).AddNode(m, &grpc.GenericServerStream[Node, NodeData]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Manager_AddNodeServer = grpc.ServerStreamingServer[NodeData] + +func _Manager_AcquireLock_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AcquireLockRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ManagerServer).AcquireLock(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Manager_AcquireLock_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ManagerServer).AcquireLock(ctx, req.(*AcquireLockRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Manager_RefreshLock_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LockData) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ManagerServer).RefreshLock(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Manager_RefreshLock_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ManagerServer).RefreshLock(ctx, req.(*LockData)) + } + return interceptor(ctx, in, info, handler) +} + +func _Manager_ReleaseLock_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LockData) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ManagerServer).ReleaseLock(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Manager_ReleaseLock_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ManagerServer).ReleaseLock(ctx, req.(*LockData)) + } + return interceptor(ctx, in, info, handler) +} + +// Manager_ServiceDesc is the grpc.ServiceDesc for Manager service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var Manager_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "manager.v1.Manager", + HandlerType: (*ManagerServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "AcquireLock", + Handler: _Manager_AcquireLock_Handler, + }, + { + MethodName: "RefreshLock", + Handler: _Manager_RefreshLock_Handler, + }, + { + MethodName: "ReleaseLock", + Handler: _Manager_ReleaseLock_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "AddNode", + Handler: _Manager_AddNode_Handler, + ServerStreams: true, + }, + }, + Metadata: "manager/manager.proto", +} diff --git a/service/node_manager/server/node.go b/service/node_manager/server/node.go new file mode 100644 index 00000000..bb3e0520 --- /dev/null +++ b/service/node_manager/server/node.go @@ -0,0 +1,203 @@ +package server + +import ( + "context" + "sync" + + "github.com/sagernet/sing-box/log" + CS "github.com/sagernet/sing-box/service/manager/constant" + pb "github.com/sagernet/sing-box/service/node_manager/manager" + E "github.com/sagernet/sing/common/exceptions" + "google.golang.org/grpc" +) + +type RemoteNode struct { + ctx context.Context + logger log.ContextLogger + stream grpc.ServerStreamingServer[pb.NodeData] + errChan chan error + + mtx sync.Mutex +} + +func NewRemoteNode(ctx context.Context, logger log.ContextLogger, stream grpc.ServerStreamingServer[pb.NodeData]) (*RemoteNode, chan error) { + errChan := make(chan error) + return &RemoteNode{ + ctx: ctx, + logger: logger, + stream: stream, + errChan: errChan, + }, errChan +} + +func (s *RemoteNode) UpdateUser(user CS.User) { + s.mtx.Lock() + defer s.mtx.Unlock() + s.send(&pb.NodeData{ + Op: pb.OpType_updateUser, + Data: &pb.NodeData_User{User: s.convertUser(user)}, + }) +} + +func (s *RemoteNode) UpdateUsers(users []CS.User) { + s.mtx.Lock() + defer s.mtx.Unlock() + pbUsers := make([]*pb.User, len(users)) + for i, user := range users { + pbUsers[i] = s.convertUser(user) + } + s.send(&pb.NodeData{ + Op: pb.OpType_updateUsers, + Data: &pb.NodeData_Users{Users: &pb.UserList{Values: pbUsers}}, + }) +} + +func (s *RemoteNode) DeleteUser(user CS.User) { + s.mtx.Lock() + defer s.mtx.Unlock() + s.send(&pb.NodeData{ + Op: pb.OpType_deleteUser, + Data: &pb.NodeData_User{User: s.convertUser(user)}, + }) +} + +func (s *RemoteNode) UpdateConnectionLimiter(limiter CS.ConnectionLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + s.send(&pb.NodeData{ + Op: pb.OpType_updateConnectionLimiter, + Data: &pb.NodeData_ConnectionLimiter{ConnectionLimiter: s.convertConnectionLimiter(limiter)}, + }) +} + +func (s *RemoteNode) UpdateConnectionLimiters(limiters []CS.ConnectionLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + pbLimiters := make([]*pb.ConnectionLimiter, len(limiters)) + for i, limiters := range limiters { + pbLimiters[i] = s.convertConnectionLimiter(limiters) + } + s.send(&pb.NodeData{ + Op: pb.OpType_updateConnectionLimiters, + Data: &pb.NodeData_ConnectionLimiters{ConnectionLimiters: &pb.ConnectionLimiterList{Values: pbLimiters}}, + }) +} + +func (s *RemoteNode) DeleteConnectionLimiter(limiter CS.ConnectionLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + s.send(&pb.NodeData{ + Op: pb.OpType_deleteConnectionLimiter, + Data: &pb.NodeData_ConnectionLimiter{ConnectionLimiter: s.convertConnectionLimiter(limiter)}, + }) +} + +func (s *RemoteNode) UpdateBandwidthLimiter(limiter CS.BandwidthLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + s.send(&pb.NodeData{ + Op: pb.OpType_updateBandwidthLimiter, + Data: &pb.NodeData_BandwidthLimiter{BandwidthLimiter: s.convertBandwidthLimiter(limiter)}, + }) +} + +func (s *RemoteNode) UpdateBandwidthLimiters(limiters []CS.BandwidthLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + pbLimiters := make([]*pb.BandwidthLimiter, len(limiters)) + for i, limiters := range limiters { + pbLimiters[i] = s.convertBandwidthLimiter(limiters) + } + s.send(&pb.NodeData{ + Op: pb.OpType_updateBandwidthLimiters, + Data: &pb.NodeData_BandwidthLimiters{BandwidthLimiters: &pb.BandwidthLimiterList{Values: pbLimiters}}, + }) +} + +func (s *RemoteNode) DeleteBandwidthLimiter(limiter CS.BandwidthLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + s.send(&pb.NodeData{ + Op: pb.OpType_deleteBandwidthLimiter, + Data: &pb.NodeData_BandwidthLimiter{BandwidthLimiter: s.convertBandwidthLimiter(limiter)}, + }) +} + +func (s *RemoteNode) IsLocal() bool { + return false +} + +func (s *RemoteNode) IsOnline() bool { + s.mtx.Lock() + defer s.mtx.Unlock() + select { + case <-s.stream.Context().Done(): + return false + default: + return true + } +} + +func (s *RemoteNode) Close() error { + s.close(E.New("server connection is closed")) + return nil +} + +func (s *RemoteNode) send(data *pb.NodeData) { + select { + case <-s.ctx.Done(): + s.close(E.New("server connection is closed")) + return + case <-s.stream.Context().Done(): + s.close(E.New("client connection is closed")) + return + default: + } + err := s.stream.Send(data) + if err != nil { + s.close(err) + } +} + +func (s *RemoteNode) close(err error) { + s.errChan <- err + close(s.errChan) +} + +func (s *RemoteNode) convertUser(user CS.User) *pb.User { + return &pb.User{ + Id: int32(user.ID), + Username: user.Username, + Type: user.Type, + Inbound: user.Inbound, + Uuid: user.UUID, + Password: user.Password, + Flow: user.Flow, + AlterId: int32(user.AlterID), + } +} + +func (s *RemoteNode) convertConnectionLimiter(limiter CS.ConnectionLimiter) *pb.ConnectionLimiter { + return &pb.ConnectionLimiter{ + Id: int32(limiter.ID), + Username: limiter.Username, + Outbound: limiter.Outbound, + Strategy: limiter.Strategy, + ConnectionType: limiter.ConnectionType, + LockType: limiter.LockType, + Count: limiter.Count, + } +} + +func (s *RemoteNode) convertBandwidthLimiter(limiter CS.BandwidthLimiter) *pb.BandwidthLimiter { + return &pb.BandwidthLimiter{ + Id: int32(limiter.ID), + Username: limiter.Username, + Outbound: limiter.Outbound, + Strategy: limiter.Strategy, + Mode: limiter.Mode, + ConnectionType: limiter.ConnectionType, + Speed: limiter.Speed, + RawSpeed: limiter.RawSpeed, + } +} diff --git a/service/node_manager/server/service.go b/service/node_manager/server/service.go new file mode 100644 index 00000000..f1a55875 --- /dev/null +++ b/service/node_manager/server/service.go @@ -0,0 +1,139 @@ +package server + +import ( + "context" + "errors" + "sync" + + "github.com/sagernet/sing-box/adapter" + boxService "github.com/sagernet/sing-box/adapter/service" + "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" + CM "github.com/sagernet/sing-box/service/manager/constant" + pb "github.com/sagernet/sing-box/service/node_manager/manager" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + N "github.com/sagernet/sing/common/network" + aTLS "github.com/sagernet/sing/common/tls" + "github.com/sagernet/sing/service" + "golang.org/x/net/http2" + "google.golang.org/grpc" +) + +func RegisterService(registry *boxService.Registry) { + boxService.Register[option.NodeManagerServerServiceOptions](registry, C.TypeNodeManagerServer, NewService) +} + +type Service struct { + pb.UnimplementedManagerServer + boxService.Adapter + + ctx context.Context + logger log.ContextLogger + listener *listener.Listener + tlsConfig tls.ServerConfig + grpcServer *grpc.Server + manager CM.Manager + options option.NodeManagerServerServiceOptions + + mtx sync.Mutex +} + +func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.NodeManagerServerServiceOptions) (adapter.Service, error) { + return &Service{ + Adapter: boxService.NewAdapter(C.TypeManager, tag), + ctx: ctx, + logger: logger, + listener: listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkTCP}, + Listen: options.ListenOptions, + }), + options: options, + }, nil +} + +func (s *Service) AddNode(node *pb.Node, stream grpc.ServerStreamingServer[pb.NodeData]) error { + remoteNode, errChan := NewRemoteNode(s.ctx, s.logger, stream) + err := s.manager.AddNode(node.Uuid, remoteNode) + if err != nil { + if err == CM.ErrNotFound { + return err + } else { + s.logger.Error(err) + return E.New("internal error") + } + } + return <-errChan +} + +func (s *Service) AcquireLock(ctx context.Context, request *pb.AcquireLockRequest) (*pb.LockData, error) { + handleId, err := s.manager.AcquireLock(int(request.LimiterId), request.Id) + if err != nil { + return nil, err + } + return &pb.LockData{HandleId: handleId}, nil +} + +func (s *Service) RefreshLock(ctx context.Context, data *pb.LockData) (*pb.Empty, error) { + return nil, s.manager.RefreshLock(int(data.LimiterId), data.Id, data.HandleId) +} + +func (s *Service) ReleaseLock(ctx context.Context, data *pb.LockData) (*pb.Empty, error) { + return nil, s.manager.ReleaseLock(int(data.LimiterId), data.Id, data.HandleId) +} + +func (s *Service) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + boxManager := service.FromContext[adapter.ServiceManager](s.ctx) + service, ok := boxManager.Get(s.options.Manager) + if !ok { + return E.New("manager ", s.options.Manager, " not found") + } + s.manager, ok = service.(CM.Manager) + if !ok { + return E.New("invalid", s.options.Manager, " manager") + } + if s.options.TLS != nil { + tlsConfig, err := tls.NewServer(s.ctx, s.logger, common.PtrValueOrDefault(s.options.TLS)) + if err != nil { + return err + } + s.tlsConfig = tlsConfig + } + if s.tlsConfig != nil { + err := s.tlsConfig.Start() + if err != nil { + return E.Cause(err, "create TLS config") + } + } + tcpListener, err := s.listener.ListenTCP() + if err != nil { + return err + } + if s.tlsConfig != nil { + if !common.Contains(s.tlsConfig.NextProtos(), http2.NextProtoTLS) { + s.tlsConfig.SetNextProtos(append([]string{"h2"}, s.tlsConfig.NextProtos()...)) + } + tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig) + } + s.grpcServer = grpc.NewServer() + pb.RegisterManagerServer(s.grpcServer, s) + go func() { + err = s.grpcServer.Serve(tcpListener) + if err != nil && !errors.Is(err, grpc.ErrServerStopped) { + s.logger.Error("serve error: ", err) + } + }() + return nil +} + +func (s *Service) Close() error { + return nil +} diff --git a/transport/v2ray/transport.go b/transport/v2ray/transport.go index e739fe3f..6afa6404 100644 --- a/transport/v2ray/transport.go +++ b/transport/v2ray/transport.go @@ -9,6 +9,7 @@ import ( "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/transport/v2rayhttp" "github.com/sagernet/sing-box/transport/v2rayhttpupgrade" + "github.com/sagernet/sing-box/transport/v2raykcp" "github.com/sagernet/sing-box/transport/v2raywebsocket" xhttp "github.com/sagernet/sing-box/transport/v2rayxhttp" E "github.com/sagernet/sing/common/exceptions" @@ -42,6 +43,8 @@ func NewServerTransport(ctx context.Context, logger logger.ContextLogger, option return v2rayhttpupgrade.NewServer(ctx, logger, options.HTTPUpgradeOptions, tlsConfig, handler) case C.V2RayTransportTypeXHTTP: return xhttp.NewServer(ctx, logger, options.XHTTPOptions, tlsConfig, handler) + case C.V2RayTransportTypeKCP: + return v2raykcp.NewServer(ctx, logger, options.KCPOptions, tlsConfig, handler) default: return nil, E.New("unknown transport type: " + options.Type) } @@ -67,6 +70,8 @@ func NewClientTransport(ctx context.Context, dialer N.Dialer, serverAddr M.Socks return v2rayhttpupgrade.NewClient(ctx, dialer, serverAddr, options.HTTPUpgradeOptions, tlsConfig) case C.V2RayTransportTypeXHTTP: return xhttp.NewClient(ctx, dialer, serverAddr, options.XHTTPOptions, tlsConfig) + case C.V2RayTransportTypeKCP: + return v2raykcp.NewClient(ctx, dialer, serverAddr, options.KCPOptions, tlsConfig) default: return nil, E.New("unknown transport type: " + options.Type) } diff --git a/transport/v2rayhttp/conn.go b/transport/v2rayhttp/conn.go index b339a753..be360897 100644 --- a/transport/v2rayhttp/conn.go +++ b/transport/v2rayhttp/conn.go @@ -265,3 +265,14 @@ func DupContext(ctx context.Context) context.Context { } return log.ContextWithID(context.Background(), id) } + +func HWIDContext(ctx context.Context, headers http.Header) context.Context { + for key, values := range headers { + if strings.ToLower(key) == "x-hwid" { + if len(values) != 0 { + return context.WithValue(ctx, "hwid", values[0]) + } + } + } + return ctx +} diff --git a/transport/v2rayhttp/server.go b/transport/v2rayhttp/server.go index 828c9f09..ef2a681d 100644 --- a/transport/v2rayhttp/server.go +++ b/transport/v2rayhttp/server.go @@ -133,7 +133,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { if requestBody != nil { conn = bufio.NewCachedConn(conn, requestBody) } - s.handler.NewConnectionEx(DupContext(request.Context()), conn, source, M.Socksaddr{}, nil) + s.handler.NewConnectionEx(HWIDContext(DupContext(request.Context()), request.Header), conn, source, M.Socksaddr{}, nil) } else { writer.WriteHeader(http.StatusOK) done := make(chan struct{}) @@ -141,7 +141,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { NewHTTPConn(request.Body, writer), writer.(http.Flusher), }) - s.handler.NewConnectionEx(request.Context(), conn, source, M.Socksaddr{}, N.OnceClose(func(it error) { + s.handler.NewConnectionEx(HWIDContext(request.Context(), request.Header), conn, source, M.Socksaddr{}, N.OnceClose(func(it error) { close(done) })) <-done diff --git a/transport/v2rayhttpupgrade/server.go b/transport/v2rayhttpupgrade/server.go index 338b7248..e2dbd682 100644 --- a/transport/v2rayhttpupgrade/server.go +++ b/transport/v2rayhttpupgrade/server.go @@ -112,7 +112,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { s.invalidRequest(writer, request, http.StatusInternalServerError, E.Cause(err, "hijack failed")) return } - s.handler.NewConnectionEx(v2rayhttp.DupContext(request.Context()), conn, sHttp.SourceAddress(request), M.Socksaddr{}, nil) + s.handler.NewConnectionEx(v2rayhttp.HWIDContext(v2rayhttp.DupContext(request.Context()), request.Header), conn, sHttp.SourceAddress(request), M.Socksaddr{}, nil) } func (s *Server) invalidRequest(writer http.ResponseWriter, request *http.Request, statusCode int, err error) { diff --git a/transport/v2raykcp/config.go b/transport/v2raykcp/config.go new file mode 100644 index 00000000..13ff34a2 --- /dev/null +++ b/transport/v2raykcp/config.go @@ -0,0 +1,128 @@ +package v2raykcp + +import ( + "crypto/cipher" + + "github.com/sagernet/sing-box/option" +) + +// Config stores the configurations for KCP transport +type Config struct { + MTU uint32 + TTI uint32 + UplinkCapacity uint32 + DownlinkCapacity uint32 + Congestion bool + ReadBufferSize uint32 + WriteBufferSize uint32 + HeaderType string + Seed string +} + +// NewConfig creates a new Config from options +func NewConfig(options option.V2RayKCPOptions) *Config { + return &Config{ + MTU: options.GetMTU(), + TTI: options.GetTTI(), + UplinkCapacity: options.GetUplinkCapacity(), + DownlinkCapacity: options.GetDownlinkCapacity(), + Congestion: options.Congestion, + ReadBufferSize: options.GetReadBufferSize(), + WriteBufferSize: options.GetWriteBufferSize(), + HeaderType: options.GetHeaderType(), + Seed: options.Seed, + } +} + +// GetMTUValue returns the value of MTU settings. +func (c *Config) GetMTUValue() uint32 { + if c == nil || c.MTU == 0 { + return 1350 + } + return c.MTU +} + +// GetTTIValue returns the value of TTI settings. +func (c *Config) GetTTIValue() uint32 { + if c == nil || c.TTI == 0 { + return 50 + } + return c.TTI +} + +// GetUplinkCapacityValue returns the value of UplinkCapacity settings. +func (c *Config) GetUplinkCapacityValue() uint32 { + if c == nil || c.UplinkCapacity == 0 { + return 12 + } + return c.UplinkCapacity +} + +// GetDownlinkCapacityValue returns the value of DownlinkCapacity settings. +func (c *Config) GetDownlinkCapacityValue() uint32 { + if c == nil || c.DownlinkCapacity == 0 { + return 100 + } + return c.DownlinkCapacity +} + +// GetWriteBufferSize returns the size of WriterBuffer in bytes. +func (c *Config) GetWriteBufferSize() uint32 { + if c == nil || c.WriteBufferSize == 0 { + return 2 * 1024 * 1024 + } + return c.WriteBufferSize * 1024 * 1024 +} + +// GetReadBufferSize returns the size of ReadBuffer in bytes. +func (c *Config) GetReadBufferSize() uint32 { + if c == nil || c.ReadBufferSize == 0 { + return 2 * 1024 * 1024 + } + return c.ReadBufferSize * 1024 * 1024 +} + +// GetSecurity returns the security settings. +func (c *Config) GetSecurity() (cipher.AEAD, error) { + if c.Seed != "" { + return NewAEADAESGCMBasedOnSeed(c.Seed), nil + } + return NewSimpleAuthenticator(), nil +} + +// GetHeaderType returns the header type +func (c *Config) GetHeaderType() string { + if c.HeaderType == "" { + return "none" + } + return c.HeaderType +} + +// GetPacketHeader builds a new PacketHeader for this config. +func (c *Config) GetPacketHeader() PacketHeader { + return NewPacketHeader(c.GetHeaderType()) +} + +func (c *Config) GetSendingInFlightSize() uint32 { + size := c.GetUplinkCapacityValue() * 1024 * 1024 / c.GetMTUValue() / (1000 / c.GetTTIValue()) + if size < 8 { + size = 8 + } + return size +} + +func (c *Config) GetSendingBufferSize() uint32 { + return c.GetWriteBufferSize() / c.GetMTUValue() +} + +func (c *Config) GetReceivingInFlightSize() uint32 { + size := c.GetDownlinkCapacityValue() * 1024 * 1024 / c.GetMTUValue() / (1000 / c.GetTTIValue()) + if size < 8 { + size = 8 + } + return size +} + +func (c *Config) GetReceivingBufferSize() uint32 { + return c.GetReadBufferSize() / c.GetMTUValue() +} diff --git a/transport/v2raykcp/connection.go b/transport/v2raykcp/connection.go new file mode 100644 index 00000000..8057e721 --- /dev/null +++ b/transport/v2raykcp/connection.go @@ -0,0 +1,566 @@ +package v2raykcp + +import ( + "bytes" + "io" + "net" + "runtime" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing/common/buf" +) + +// PacketWriter writes low-level UDP packets with obfuscating header and AEAD. +// It mirrors v2ray-core's kcp.PacketWriter. +type PacketWriter interface { + Overhead() int + io.Writer +} + +// State of the connection +type State int32 + +const ( + StateActive State = 0 + StateReadyToClose State = 1 + StatePeerClosed State = 2 + StateTerminating State = 3 + StatePeerTerminating State = 4 + StateTerminated State = 5 +) + +// Is returns true if current State is one of the candidates. +func (s State) Is(states ...State) bool { + for _, state := range states { + if s == state { + return true + } + } + return false +} + +func nowMillisec() int64 { + now := time.Now() + return now.Unix()*1000 + int64(now.Nanosecond()/1000000) +} + +// RoundTripInfo stores round trip time information +type RoundTripInfo struct { + mu sync.RWMutex + variation uint32 + srtt uint32 + rto uint32 + minRtt uint32 + updatedTimestamp uint32 +} + +func (info *RoundTripInfo) UpdatePeerRTO(rto uint32, current uint32) { + info.mu.Lock() + defer info.mu.Unlock() + + if current-info.updatedTimestamp < 3000 { + return + } + info.updatedTimestamp = current + info.rto = rto +} + +func (info *RoundTripInfo) Update(rtt uint32, current uint32) { + if rtt > 0x7FFFFFFF { + return + } + + info.mu.Lock() + defer info.mu.Unlock() + + if info.srtt == 0 { + info.srtt = rtt + info.variation = rtt / 2 + } else { + delta := rtt - info.srtt + if info.srtt > rtt { + delta = info.srtt - rtt + } + info.variation = (3*info.variation + delta) / 4 + info.srtt = (7*info.srtt + rtt) / 8 + if info.srtt < info.minRtt { + info.srtt = info.minRtt + } + } + + var rto uint32 + if info.minRtt < 4*info.variation { + rto = info.srtt + 4*info.variation + } else { + rto = info.srtt + info.variation + } + + if rto > 10000 { + rto = 10000 + } + info.rto = rto * 5 / 4 + info.updatedTimestamp = current +} + +func (info *RoundTripInfo) Timeout() uint32 { + info.mu.RLock() + defer info.mu.RUnlock() + + if info.rto == 0 { + return 100 + } + return info.rto +} + +func (info *RoundTripInfo) SmoothedTime() uint32 { + info.mu.RLock() + defer info.mu.RUnlock() + + return info.srtt +} + +// ConnMetadata stores connection metadata +type ConnMetadata struct { + LocalAddr net.Addr + RemoteAddr net.Addr + Conversation uint16 +} + +// Connection represents a KCP connection +type Connection struct { + meta ConnMetadata + closer io.Closer + rd time.Time + wd time.Time + since int64 + dataInput chan struct{} + dataOutput chan struct{} + Config *Config + state int32 + stateBeginTime uint32 + lastIncomingTime uint32 + lastPingTime uint32 + mss uint32 + roundTrip *RoundTripInfo + receivingWorker *ReceivingWorker + sendingWorker *SendingWorker + output SegmentWriter + dataUpdater *Updater + pingUpdater *Updater +} + +func NewConnection(meta ConnMetadata, writer PacketWriter, closer io.Closer, config *Config) *Connection { + conn := &Connection{ + meta: meta, + closer: closer, + since: nowMillisec(), + dataInput: make(chan struct{}, 1), + dataOutput: make(chan struct{}, 1), + Config: config, + output: NewSegmentWriter(writer), + mss: config.GetMTUValue() - uint32(writer.Overhead()) - uint32(DataSegmentOverhead), + roundTrip: &RoundTripInfo{ + rto: 100, + minRtt: config.GetTTIValue(), + }, + } + + conn.receivingWorker = NewReceivingWorker(conn) + conn.sendingWorker = NewSendingWorker(conn) + + isTerminating := func() bool { + return conn.State().Is(StateTerminating, StateTerminated) + } + isTerminated := func() bool { + return conn.State() == StateTerminated + } + + conn.dataUpdater = NewUpdater( + config.GetTTIValue(), + func() bool { + return !isTerminating() && (conn.sendingWorker.UpdateNecessary() || conn.receivingWorker.UpdateNecessary()) + }, + isTerminating, + conn.updateTask) + conn.pingUpdater = NewUpdater( + 5000, + func() bool { return !isTerminated() }, + isTerminated, + conn.updateTask) + conn.pingUpdater.WakeUp() + + return conn +} + +func (c *Connection) Elapsed() uint32 { + return uint32(nowMillisec() - c.since) +} + +func (c *Connection) State() State { + return State(atomic.LoadInt32(&c.state)) +} + +func (c *Connection) SetState(state State) { + current := c.Elapsed() + atomic.StoreInt32(&c.state, int32(state)) + atomic.StoreUint32(&c.stateBeginTime, current) + + switch state { + case StateReadyToClose: + c.receivingWorker.CloseRead() + case StatePeerClosed: + c.sendingWorker.CloseWrite() + case StateTerminating: + c.receivingWorker.CloseRead() + c.sendingWorker.CloseWrite() + c.pingUpdater.SetInterval(time.Second) + case StatePeerTerminating: + c.sendingWorker.CloseWrite() + c.pingUpdater.SetInterval(time.Second) + case StateTerminated: + c.receivingWorker.CloseRead() + c.sendingWorker.CloseWrite() + c.pingUpdater.SetInterval(time.Second) + c.dataUpdater.WakeUp() + c.pingUpdater.WakeUp() + go c.Terminate() + } +} + +func (c *Connection) Terminate() { + if c == nil { + return + } + time.Sleep(8 * time.Second) + + if c.closer != nil { + c.closer.Close() + } + if c.sendingWorker != nil { + c.sendingWorker.Release() + } + if c.receivingWorker != nil { + c.receivingWorker.Release() + } +} + +func (c *Connection) HandleOption(opt SegmentOption) { + if (opt & SegmentOptionClose) == SegmentOptionClose { + c.OnPeerClosed() + } +} + +func (c *Connection) OnPeerClosed() { + switch c.State() { + case StateReadyToClose: + c.SetState(StateTerminating) + case StateActive: + c.SetState(StatePeerClosed) + } +} + +func (c *Connection) Input(segments []Segment) { + current := c.Elapsed() + atomic.StoreUint32(&c.lastIncomingTime, current) + + for _, s := range segments { + if s.Conversation() != c.meta.Conversation { + break + } + + switch seg := s.(type) { + case *DataSegment: + c.HandleOption(seg.Option) + c.receivingWorker.ProcessSegment(seg) + if c.receivingWorker.IsDataAvailable() { + select { + case c.dataInput <- struct{}{}: + default: + } + } + c.dataUpdater.WakeUp() + case *AckSegment: + c.HandleOption(seg.Option) + c.sendingWorker.ProcessSegment(current, seg, c.roundTrip.Timeout()) + select { + case c.dataOutput <- struct{}{}: + default: + } + c.dataUpdater.WakeUp() + case *CmdOnlySegment: + c.HandleOption(seg.Option) + if seg.Command() == CommandTerminate { + switch c.State() { + case StateActive, StatePeerClosed: + c.SetState(StatePeerTerminating) + case StateReadyToClose: + c.SetState(StateTerminating) + case StateTerminating: + c.SetState(StateTerminated) + } + } + if seg.Option == SegmentOptionClose || seg.Command() == CommandTerminate { + select { + case c.dataInput <- struct{}{}: + default: + } + select { + case c.dataOutput <- struct{}{}: + default: + } + } + c.sendingWorker.ProcessReceivingNext(seg.ReceivingNext) + c.receivingWorker.ProcessSendingNext(seg.SendingNext) + c.roundTrip.UpdatePeerRTO(seg.PeerRTO, current) + seg.Release() + default: + s.Release() + } + } +} + +func (c *Connection) waitForDataInput() error { + for i := 0; i < 16; i++ { + select { + case <-c.dataInput: + return nil + default: + runtime.Gosched() + } + } + + duration := time.Second * 16 + if !c.rd.IsZero() { + duration = time.Until(c.rd) + if duration < 0 { + return ErrIOTimeout + } + } + + select { + case <-c.dataInput: + return nil + case <-time.After(duration): + if !c.rd.IsZero() && c.rd.Before(time.Now()) { + return ErrIOTimeout + } + return nil + } +} + +func (c *Connection) Read(b []byte) (int, error) { + if c == nil { + return 0, io.EOF + } + + for { + if c.State().Is(StateReadyToClose, StateTerminating, StateTerminated) { + return 0, io.EOF + } + + nBytes := c.receivingWorker.Read(b) + if nBytes > 0 { + c.dataUpdater.WakeUp() + return nBytes, nil + } + + if c.State() == StatePeerTerminating { + return 0, io.EOF + } + + if err := c.waitForDataInput(); err != nil { + return 0, err + } + } +} + +func (c *Connection) waitForDataOutput() error { + for i := 0; i < 16; i++ { + select { + case <-c.dataOutput: + return nil + default: + runtime.Gosched() + } + } + + duration := time.Second * 16 + if !c.wd.IsZero() { + duration = time.Until(c.wd) + if duration < 0 { + return ErrIOTimeout + } + } + + select { + case <-c.dataOutput: + return nil + case <-time.After(duration): + if !c.wd.IsZero() && c.wd.Before(time.Now()) { + return ErrIOTimeout + } + return nil + } +} + +func (c *Connection) Write(b []byte) (int, error) { + if c.State() != StateActive { + return 0, io.ErrClosedPipe + } + + totalWritten := 0 + reader := bytes.NewReader(b) + + for reader.Len() > 0 { + buffer := buf.New() + n, _ := buffer.ReadFrom(io.LimitReader(reader, int64(c.mss))) + if n == 0 { + buffer.Release() + break + } + + for !c.sendingWorker.Push(buffer) { + if c.State() != StateActive { + buffer.Release() + return totalWritten, io.ErrClosedPipe + } + + c.dataUpdater.WakeUp() + + if err := c.waitForDataOutput(); err != nil { + buffer.Release() + return totalWritten, err + } + } + + totalWritten += int(n) + } + + c.dataUpdater.WakeUp() + return totalWritten, nil +} + +func (c *Connection) updateTask() { + current := c.Elapsed() + + if c.State() == StateTerminated { + return + } + if c.State() == StateActive && current-atomic.LoadUint32(&c.lastIncomingTime) >= 30000 { + _ = c.Close() + } + if c.State() == StateReadyToClose && c.sendingWorker.IsEmpty() { + c.SetState(StateTerminating) + } + if c.State() == StateTerminating { + if current-atomic.LoadUint32(&c.stateBeginTime) > 8000 { + c.SetState(StateTerminated) + } else { + c.Ping(current, CommandTerminate) + } + return + } + if c.State() == StatePeerTerminating && current-atomic.LoadUint32(&c.stateBeginTime) > 4000 { + c.SetState(StateTerminating) + } + if c.State() == StateReadyToClose && current-atomic.LoadUint32(&c.stateBeginTime) > 15000 { + c.SetState(StateTerminating) + } + + c.receivingWorker.Flush(current) + c.sendingWorker.Flush(current) + + if current-atomic.LoadUint32(&c.lastPingTime) >= 3000 { + c.Ping(current, CommandPing) + } + + select { + case c.dataOutput <- struct{}{}: + default: + } +} + +func (c *Connection) Close() error { + if c == nil { + return ErrClosedConnection + } + + select { + case c.dataInput <- struct{}{}: + default: + } + select { + case c.dataOutput <- struct{}{}: + default: + } + + switch c.State() { + case StateReadyToClose, StateTerminating, StateTerminated: + return ErrClosedConnection + case StateActive: + c.SetState(StateReadyToClose) + case StatePeerClosed: + c.SetState(StateTerminating) + case StatePeerTerminating: + c.SetState(StateTerminated) + } + + return nil +} + +func (c *Connection) LocalAddr() net.Addr { + if c == nil { + return nil + } + return c.meta.LocalAddr +} + +func (c *Connection) RemoteAddr() net.Addr { + if c == nil { + return nil + } + return c.meta.RemoteAddr +} + +func (c *Connection) SetDeadline(t time.Time) error { + if err := c.SetReadDeadline(t); err != nil { + return err + } + if err := c.SetWriteDeadline(t); err != nil { + return err + } + return nil +} + +func (c *Connection) SetReadDeadline(t time.Time) error { + if c == nil { + return ErrClosedConnection + } + c.rd = t + return nil +} + +func (c *Connection) SetWriteDeadline(t time.Time) error { + if c == nil { + return ErrClosedConnection + } + c.wd = t + return nil +} + +func (c *Connection) Ping(current uint32, cmd Command) { + seg := NewCmdOnlySegment() + seg.Conv = c.meta.Conversation + seg.Cmd = cmd + seg.SendingNext = c.sendingWorker.FirstUnacknowledged() + seg.ReceivingNext = c.receivingWorker.NextNumber() + seg.PeerRTO = c.roundTrip.Timeout() + if c.State() == StateReadyToClose { + seg.Option = SegmentOptionClose + } + c.output.Write(seg) + atomic.StoreUint32(&c.lastPingTime, current) + seg.Release() +} diff --git a/transport/v2raykcp/crypt.go b/transport/v2raykcp/crypt.go new file mode 100644 index 00000000..e9773f1b --- /dev/null +++ b/transport/v2raykcp/crypt.go @@ -0,0 +1,109 @@ +package v2raykcp + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/sha256" + "encoding/binary" + "hash/fnv" +) + +// SimpleAuthenticator is a legacy AEAD used for KCP encryption. +type SimpleAuthenticator struct{} + +// NewSimpleAuthenticator creates a new SimpleAuthenticator +func NewSimpleAuthenticator() cipher.AEAD { + return &SimpleAuthenticator{} +} + +// NonceSize implements cipher.AEAD.NonceSize(). +func (*SimpleAuthenticator) NonceSize() int { + return 0 +} + +// Overhead implements cipher.AEAD.Overhead(). +func (*SimpleAuthenticator) Overhead() int { + return 6 +} + +// Seal implements cipher.AEAD.Seal(). +func (a *SimpleAuthenticator) Seal(dst, nonce, plain, extra []byte) []byte { + dst = append(dst, 0, 0, 0, 0, 0, 0) // 4 bytes for hash, and then 2 bytes for length + binary.BigEndian.PutUint16(dst[4:], uint16(len(plain))) + dst = append(dst, plain...) + + fnvHash := fnv.New32a() + fnvHash.Write(dst[4:]) + fnvHash.Sum(dst[:0]) + + dstLen := len(dst) + xtra := 4 - dstLen%4 + if xtra != 4 { + dst = append(dst, make([]byte, xtra)...) + } + xorfwd(dst) + if xtra != 4 { + dst = dst[:dstLen] + } + return dst +} + +// Open implements cipher.AEAD.Open(). +func (a *SimpleAuthenticator) Open(dst, nonce, cipherText, extra []byte) ([]byte, error) { + dst = append(dst, cipherText...) + dstLen := len(dst) + xtra := 4 - dstLen%4 + if xtra != 4 { + dst = append(dst, make([]byte, xtra)...) + } + xorbkd(dst) + if xtra != 4 { + dst = dst[:dstLen] + } + + fnvHash := fnv.New32a() + fnvHash.Write(dst[4:]) + if binary.BigEndian.Uint32(dst[:4]) != fnvHash.Sum32() { + return nil, newError("invalid auth") + } + + length := binary.BigEndian.Uint16(dst[4:6]) + if len(dst)-6 != int(length) { + return nil, newError("invalid auth") + } + + return dst[6:], nil +} + +// xorfwd performs XOR forwards in words, x[i] ^= x[i-4], i from 0 to len. +func xorfwd(b []byte) { + for i := 4; i < len(b); i++ { + b[i] ^= b[i-4] + } +} + +// xorbkd performs XOR backwards in words, x[i] ^= x[i-4], i from len to 0. +func xorbkd(b []byte) { + for i := len(b) - 1; i >= 4; i-- { + b[i] ^= b[i-4] + } +} + +// NewAEADAESGCMBasedOnSeed creates a new AES-GCM AEAD based on a seed +func NewAEADAESGCMBasedOnSeed(seed string) cipher.AEAD { + // Use SHA256 to hash the seed + hashedSeed := sha256.Sum256([]byte(seed)) + + // Use first 16 bytes as AES-128 key + block, err := aes.NewCipher(hashedSeed[:16]) + if err != nil { + panic(err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + panic(err) + } + + return gcm +} diff --git a/transport/v2raykcp/dialer.go b/transport/v2raykcp/dialer.go new file mode 100644 index 00000000..4a5fb7cd --- /dev/null +++ b/transport/v2raykcp/dialer.go @@ -0,0 +1,231 @@ +package v2raykcp + +import ( + "context" + "crypto/cipher" + "crypto/rand" + "encoding/binary" + "net" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +var _ adapter.V2RayClientTransport = (*Client)(nil) + +type Client struct { + ctx context.Context + dialer N.Dialer + serverAddr M.Socksaddr + config *Config + tlsConfig tls.Config +} + +func NewClient( + ctx context.Context, + dialer N.Dialer, + serverAddr M.Socksaddr, + options option.V2RayKCPOptions, + tlsConfig tls.Config, +) (adapter.V2RayClientTransport, error) { + return &Client{ + ctx: ctx, + dialer: dialer, + serverAddr: serverAddr, + config: NewConfig(options), + tlsConfig: tlsConfig, + }, nil +} + +func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { + // Dial UDP connection + udpConn, err := c.dialer.DialContext(ctx, N.NetworkUDP, c.serverAddr) + if err != nil { + return nil, E.Cause(err, "dial UDP") + } + + // Wrap as PacketConn + packetConn := bufio.NewUnbindPacketConn(udpConn) + + // Generate conversation ID + var convID uint16 + binary.Read(rand.Reader, binary.BigEndian, &convID) + + // Create KCP connection + kcpConn, err := c.createConnection(ctx, packetConn, c.serverAddr.UDPAddr(), convID) + if err != nil { + udpConn.Close() + return nil, E.Cause(err, "create KCP connection") + } + + // Wrap with TLS if configured + if c.tlsConfig != nil { + tlsConn, err := tls.ClientHandshake(ctx, kcpConn, c.tlsConfig) + if err != nil { + kcpConn.Close() + return nil, E.Cause(err, "TLS handshake") + } + return tlsConn, nil + } + + return kcpConn, nil +} + +func (c *Client) Close() error { + return nil +} + +func (c *Client) createConnection(ctx context.Context, conn N.PacketConn, remoteAddr *net.UDPAddr, convID uint16) (*Connection, error) { + security, err := c.config.GetSecurity() + if err != nil { + return nil, E.Cause(err, "get security") + } + + // Create packet header + header := c.config.GetPacketHeader() + + // Create packet writer + writer := &kcpPacketWriter{ + conn: conn, + remoteAddr: remoteAddr, + header: header, + security: security, + } + + // Create packet reader + reader := &kcpPacketReader{ + security: security, + headerSize: HeaderSize(c.config.GetHeaderType()), + } + + // Create connection metadata + meta := ConnMetadata{ + LocalAddr: conn.LocalAddr(), + RemoteAddr: remoteAddr, + Conversation: convID, + } + + // Create KCP connection + kcpConn := NewConnection(meta, writer, conn, c.config) + + // Start reading goroutine + go c.readLoop(ctx, conn, reader, kcpConn) + + return kcpConn, nil +} + +func (c *Client) readLoop(ctx context.Context, conn N.PacketConn, reader *kcpPacketReader, kcpConn *Connection) { + for { + select { + case <-ctx.Done(): + return + default: + } + + buffer := buf.New() + _, err := conn.ReadPacket(buffer) + if err != nil { + buffer.Release() + return + } + + segments := reader.Read(buffer.Bytes()) + buffer.Release() + + if len(segments) > 0 { + kcpConn.Input(segments) + } + } +} + +type kcpPacketWriter struct { + conn N.PacketConn + remoteAddr *net.UDPAddr + header PacketHeader + security cipher.AEAD +} + +func (w *kcpPacketWriter) Overhead() int { + overhead := 0 + if w.header != nil { + overhead += w.header.Size() + } + if w.security != nil { + overhead += w.security.Overhead() + } + return overhead +} + +func (w *kcpPacketWriter) Write(b []byte) (int, error) { + packet := buf.New() + defer packet.Release() + + if w.header != nil { + headerBytes := packet.Extend(w.header.Size()) + w.header.Serialize(headerBytes) + } + + if w.security != nil { + nonceSize := w.security.NonceSize() + nonce := packet.Extend(nonceSize) + common.Must1(rand.Read(nonce)) + + encrypted := w.security.Seal(nil, nonce, b, nil) + packet.Write(encrypted) + } else { + packet.Write(b) + } + + destAddr := M.SocksaddrFromNet(w.remoteAddr) + err := w.conn.WritePacket(packet, destAddr) + if err != nil { + return 0, err + } + + return len(b), nil +} + +type kcpPacketReader struct { + security cipher.AEAD + headerSize int +} + +func (r *kcpPacketReader) Read(b []byte) []Segment { + if r.headerSize > 0 { + if len(b) <= r.headerSize { + return nil + } + b = b[r.headerSize:] + } + + if r.security != nil { + nonceSize := r.security.NonceSize() + overhead := r.security.Overhead() + if len(b) <= nonceSize+overhead { + return nil + } + out, err := r.security.Open(nil, b[:nonceSize], b[nonceSize:], nil) + if err != nil { + return nil + } + b = out + } + + var result []Segment + for len(b) > 0 { + seg, extra := ReadSegment(b) + if seg == nil { + break + } + result = append(result, seg) + b = extra + } + return result +} diff --git a/transport/v2raykcp/errors.go b/transport/v2raykcp/errors.go new file mode 100644 index 00000000..4f46a4f7 --- /dev/null +++ b/transport/v2raykcp/errors.go @@ -0,0 +1,29 @@ +package v2raykcp + +import "errors" + +var ( + // ErrIOTimeout is returned when I/O operation times out + ErrIOTimeout = errors.New("i/o timeout") + // ErrClosedListener is returned when listener is closed + ErrClosedListener = errors.New("listener closed") + // ErrClosedConnection is returned when connection is closed + ErrClosedConnection = errors.New("connection closed") +) + +func newError(values ...interface{}) error { + return errors.New(toString(values...)) +} + +func toString(values ...interface{}) string { + result := "" + for _, value := range values { + switch v := value.(type) { + case string: + result += v + case error: + result += v.Error() + } + } + return result +} diff --git a/transport/v2raykcp/header.go b/transport/v2raykcp/header.go new file mode 100644 index 00000000..b281decd --- /dev/null +++ b/transport/v2raykcp/header.go @@ -0,0 +1,202 @@ +package v2raykcp + +import ( + "crypto/rand" + "encoding/binary" +) + +// used only by KCP to add an obfuscating header before encrypted payload. +type PacketHeader interface { + Size() int + Serialize([]byte) +} + +// NewPacketHeader creates a new PacketHeader instance for the given header type. +// Supported values: none, srtp, utp, wechat-video, +// dtls, wireguard. Unknown types fall back to no header. +func NewPacketHeader(headerType string) PacketHeader { + switch headerType { + case "srtp": + return newSRTPHeader() + case "utp": + return newUTPHeader() + case "wechat-video": + return newWechatVideoHeader() + case "dtls": + return newDTLSHeader() + case "wireguard": + return newWireguardHeader() + default: + return nil + } +} + +// HeaderSize returns the byte size of the header for the given type. +func HeaderSize(headerType string) int { + switch headerType { + case "srtp", "utp", "wireguard": + return 4 + case "wechat-video", "dtls": + return 13 + default: + return 0 + } +} + +// ----- SRTP ----- + +type srtpHeader struct { + header uint16 + number uint16 +} + +func newSRTPHeader() *srtpHeader { + return &srtpHeader{ + header: 0xB5E8, + number: randomUint16(), + } +} + +func (*srtpHeader) Size() int { + return 4 +} + +func (s *srtpHeader) Serialize(b []byte) { + s.number++ + binary.BigEndian.PutUint16(b, s.header) + binary.BigEndian.PutUint16(b[2:], s.number) +} + +// ----- UTP ----- + +type utpHeader struct { + header byte + extension byte + connectionID uint16 +} + +func newUTPHeader() *utpHeader { + return &utpHeader{ + header: 1, + extension: 0, + connectionID: randomUint16(), + } +} + +func (*utpHeader) Size() int { + return 4 +} + +func (u *utpHeader) Serialize(b []byte) { + binary.BigEndian.PutUint16(b, u.connectionID) + b[2] = u.header + b[3] = u.extension +} + +// ----- WeChat Video ----- + +type wechatVideoHeader struct { + sn uint32 +} + +func newWechatVideoHeader() *wechatVideoHeader { + return &wechatVideoHeader{ + sn: randomUint32(), + } +} + +func (*wechatVideoHeader) Size() int { + return 13 +} + +func (vc *wechatVideoHeader) Serialize(b []byte) { + vc.sn++ + b[0] = 0xa1 + b[1] = 0x08 + binary.BigEndian.PutUint32(b[2:], vc.sn) + b[6] = 0x00 + b[7] = 0x10 + b[8] = 0x11 + b[9] = 0x18 + b[10] = 0x30 + b[11] = 0x22 + b[12] = 0x30 +} + +// ----- DTLS ----- + +type dtlsHeader struct { + epoch uint16 + length uint16 + sequence uint32 +} + +func newDTLSHeader() *dtlsHeader { + return &dtlsHeader{ + epoch: randomUint16(), + sequence: 0, + length: 17, + } +} + +func (*dtlsHeader) Size() int { + return 13 +} + +func (d *dtlsHeader) Serialize(b []byte) { + b[0] = 23 // application data + b[1] = 254 + b[2] = 253 + b[3] = byte(d.epoch >> 8) + b[4] = byte(d.epoch) + b[5] = 0 + b[6] = 0 + b[7] = byte(d.sequence >> 24) + b[8] = byte(d.sequence >> 16) + b[9] = byte(d.sequence >> 8) + b[10] = byte(d.sequence) + d.sequence++ + b[11] = byte(d.length >> 8) + b[12] = byte(d.length) + d.length += 17 + if d.length > 100 { + d.length -= 50 + } +} + +// ----- WireGuard ----- + +type wireguardHeader struct{} + +func newWireguardHeader() *wireguardHeader { + return &wireguardHeader{} +} + +func (*wireguardHeader) Size() int { + return 4 +} + +func (*wireguardHeader) Serialize(b []byte) { + b[0] = 0x04 + b[1] = 0x00 + b[2] = 0x00 + b[3] = 0x00 +} + +// ----- helpers ----- + +func randomUint16() uint16 { + var b [2]byte + if _, err := rand.Read(b[:]); err != nil { + return 0 + } + return binary.BigEndian.Uint16(b[:]) +} + +func randomUint32() uint32 { + var b [4]byte + if _, err := rand.Read(b[:]); err != nil { + return 0 + } + return binary.BigEndian.Uint32(b[:]) +} diff --git a/transport/v2raykcp/listener.go b/transport/v2raykcp/listener.go new file mode 100644 index 00000000..5678b251 --- /dev/null +++ b/transport/v2raykcp/listener.go @@ -0,0 +1,227 @@ +package v2raykcp + +import ( + "context" + "crypto/cipher" + "crypto/rand" + "net" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + 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" +) + +var _ adapter.V2RayServerTransport = (*Server)(nil) + +type Server struct { + ctx context.Context + logger logger.ContextLogger + config *Config + tlsConfig tls.ServerConfig + handler adapter.V2RayServerTransportHandler + listener *net.UDPConn + sessions sync.Map // map[ConnectionID]*Connection + security cipher.AEAD + headerSize int +} + +type ConnectionID struct { + Remote string + Port uint16 + Conv uint16 +} + +func NewServer( + ctx context.Context, + logger logger.ContextLogger, + options option.V2RayKCPOptions, + tlsConfig tls.ServerConfig, + handler adapter.V2RayServerTransportHandler, +) (adapter.V2RayServerTransport, error) { + config := NewConfig(options) + security, err := config.GetSecurity() + if err != nil { + return nil, E.Cause(err, "get security") + } + + return &Server{ + ctx: ctx, + logger: logger, + config: config, + tlsConfig: tlsConfig, + handler: handler, + security: security, + headerSize: HeaderSize(config.GetHeaderType()), + }, nil +} + +func (s *Server) Network() []string { + return []string{N.NetworkUDP} +} + +func (s *Server) Serve(listener net.Listener) error { + return E.New("KCP server requires ServePacket") +} + +func (s *Server) ServePacket(listener net.PacketConn) error { + udpConn, ok := listener.(*net.UDPConn) + if !ok { + return E.New("KCP requires UDP listener") + } + + s.listener = udpConn + s.logger.Info("KCP server started") + + buffer := make([]byte, 2048) + for { + n, remoteAddr, err := udpConn.ReadFrom(buffer) + if err != nil { + if E.IsClosed(err) { + return nil + } + return err + } + + go s.handlePacket(buffer[:n], remoteAddr) + } +} + +func (s *Server) handlePacket(data []byte, remoteAddr net.Addr) { + reader := &kcpPacketReader{ + security: s.security, + headerSize: s.headerSize, + } + + segments := reader.Read(data) + if len(segments) == 0 { + return + } + + firstSeg := segments[0] + conv := firstSeg.Conversation() + cmd := firstSeg.Command() + + udpAddr, ok := remoteAddr.(*net.UDPAddr) + if !ok { + return + } + + connID := ConnectionID{ + Remote: udpAddr.IP.String(), + Port: uint16(udpAddr.Port), + Conv: conv, + } + + value, exists := s.sessions.Load(connID) + if !exists { + if cmd == CommandTerminate { + return + } + + // Create new connection + writer := &serverPacketWriter{ + conn: s.listener, + remoteAddr: udpAddr, + server: s, + connID: connID, + header: s.config.GetPacketHeader(), + security: s.security, + } + + meta := ConnMetadata{ + LocalAddr: s.listener.LocalAddr(), + RemoteAddr: udpAddr, + Conversation: conv, + } + + kcpConn := NewConnection(meta, writer, writer, s.config) + s.sessions.Store(connID, kcpConn) + + var netConn net.Conn = kcpConn + if s.tlsConfig != nil { + tlsConn, err := tls.ServerHandshake(s.ctx, kcpConn, s.tlsConfig) + if err != nil { + kcpConn.Close() + s.sessions.Delete(connID) + return + } + netConn = tlsConn + } + + source := M.SocksaddrFromNet(remoteAddr) + go s.handler.NewConnectionEx(s.ctx, netConn, source, M.Socksaddr{}, nil) + + kcpConn.Input(segments) + } else { + conn := value.(*Connection) + conn.Input(segments) + } +} + +func (s *Server) Close() error { + s.sessions.Range(func(key, value interface{}) bool { + conn := value.(*Connection) + conn.Close() + return true + }) + if s.listener != nil { + return s.listener.Close() + } + return nil +} + +type serverPacketWriter struct { + conn *net.UDPConn + remoteAddr *net.UDPAddr + server *Server + connID ConnectionID + header PacketHeader + security cipher.AEAD +} + +func (w *serverPacketWriter) Overhead() int { + overhead := 0 + if w.header != nil { + overhead += w.header.Size() + } + if w.security != nil { + overhead += w.security.Overhead() + } + return overhead +} + +func (w *serverPacketWriter) Write(b []byte) (int, error) { + buffer := buf.New() + defer buffer.Release() + + if w.header != nil { + headerBytes := buffer.Extend(w.header.Size()) + w.header.Serialize(headerBytes) + } + + if w.security != nil { + nonceSize := w.security.NonceSize() + nonce := buffer.Extend(nonceSize) + common.Must1(rand.Read(nonce)) + + encrypted := w.security.Seal(nil, nonce, b, nil) + buffer.Write(encrypted) + } else { + buffer.Write(b) + } + + _, err := w.conn.WriteTo(buffer.Bytes(), w.remoteAddr) + return len(b), err +} + +func (w *serverPacketWriter) Close() error { + w.server.sessions.Delete(w.connID) + return nil +} diff --git a/transport/v2raykcp/multi_buffer.go b/transport/v2raykcp/multi_buffer.go new file mode 100644 index 00000000..9482e690 --- /dev/null +++ b/transport/v2raykcp/multi_buffer.go @@ -0,0 +1,52 @@ +package v2raykcp + +import "github.com/sagernet/sing/common/buf" + +// MultiBuffer is a list of buf.Buffer. The order of Buffer matters. +type MultiBuffer []*buf.Buffer + +// ReleaseMulti releases all content of the MultiBuffer and returns an empty MultiBuffer. +func ReleaseMulti(mb MultiBuffer) MultiBuffer { + for i := range mb { + mb[i].Release() + mb[i] = nil + } + return mb[:0] +} + +// SplitBytes splits the given amount of bytes from the beginning of the MultiBuffer. +// It returns the new MultiBuffer leftover and number of bytes written into the input byte slice. +func SplitBytes(mb MultiBuffer, b []byte) (MultiBuffer, int) { + totalBytes := 0 + endIndex := -1 + for i := range mb { + pBuffer := mb[i] + nBytes, _ := pBuffer.Read(b) + totalBytes += nBytes + b = b[nBytes:] + if !pBuffer.IsEmpty() { + endIndex = i + break + } + pBuffer.Release() + mb[i] = nil + } + + if endIndex == -1 { + mb = mb[:0] + } else { + mb = mb[endIndex:] + } + + return mb, totalBytes +} + +// IsEmpty returns true if the MultiBuffer has no content. +func (mb MultiBuffer) IsEmpty() bool { + for _, b := range mb { + if !b.IsEmpty() { + return false + } + } + return true +} diff --git a/transport/v2raykcp/output.go b/transport/v2raykcp/output.go new file mode 100644 index 00000000..b4469bc0 --- /dev/null +++ b/transport/v2raykcp/output.go @@ -0,0 +1,36 @@ +package v2raykcp + +import ( + "io" + "sync" +) + +type SegmentWriter interface { + Write(Segment) error +} + +type SimpleSegmentWriter struct { + sync.Mutex + buffer []byte + writer io.Writer +} + +func NewSegmentWriter(writer io.Writer) SegmentWriter { + return &SimpleSegmentWriter{ + buffer: make([]byte, 2048), + writer: writer, + } +} + +func (w *SimpleSegmentWriter) Write(seg Segment) error { + w.Lock() + defer w.Unlock() + + segSize := seg.ByteSize() + if int(segSize) > len(w.buffer) { + w.buffer = make([]byte, segSize) + } + seg.Serialize(w.buffer[:segSize]) + _, err := w.writer.Write(w.buffer[:segSize]) + return err +} diff --git a/transport/v2raykcp/receiving.go b/transport/v2raykcp/receiving.go new file mode 100644 index 00000000..b70aada2 --- /dev/null +++ b/transport/v2raykcp/receiving.go @@ -0,0 +1,254 @@ +package v2raykcp + +import "sync" + +type ReceivingWindow struct { + cache map[uint32]*DataSegment +} + +func NewReceivingWindow() *ReceivingWindow { + return &ReceivingWindow{ + cache: make(map[uint32]*DataSegment), + } +} + +func (w *ReceivingWindow) Set(id uint32, value *DataSegment) bool { + _, f := w.cache[id] + if f { + return false + } + w.cache[id] = value + return true +} + +func (w *ReceivingWindow) Has(id uint32) bool { + _, f := w.cache[id] + return f +} + +func (w *ReceivingWindow) Remove(id uint32) *DataSegment { + v, f := w.cache[id] + if !f { + return nil + } + delete(w.cache, id) + return v +} + +type AckList struct { + writer SegmentWriter + timestamps []uint32 + numbers []uint32 + nextFlush []uint32 + + flushCandidates []uint32 + dirty bool +} + +func NewAckList(writer SegmentWriter) *AckList { + return &AckList{ + writer: writer, + timestamps: make([]uint32, 0, 128), + numbers: make([]uint32, 0, 128), + nextFlush: make([]uint32, 0, 128), + flushCandidates: make([]uint32, 0, 128), + } +} + +func (l *AckList) Add(number uint32, timestamp uint32) { + l.timestamps = append(l.timestamps, timestamp) + l.numbers = append(l.numbers, number) + l.nextFlush = append(l.nextFlush, 0) + l.dirty = true +} + +func (l *AckList) Clear(una uint32) { + count := 0 + for i := 0; i < len(l.numbers); i++ { + if l.numbers[i] < una { + continue + } + if i != count { + l.numbers[count] = l.numbers[i] + l.timestamps[count] = l.timestamps[i] + l.nextFlush[count] = l.nextFlush[i] + } + count++ + } + if count < len(l.numbers) { + l.numbers = l.numbers[:count] + l.timestamps = l.timestamps[:count] + l.nextFlush = l.nextFlush[:count] + l.dirty = true + } +} + +func (l *AckList) Flush(current uint32, rto uint32) { + l.flushCandidates = l.flushCandidates[:0] + + seg := NewAckSegment() + for i := 0; i < len(l.numbers); i++ { + if l.nextFlush[i] > current { + if len(l.flushCandidates) < cap(l.flushCandidates) { + l.flushCandidates = append(l.flushCandidates, l.numbers[i]) + } + continue + } + seg.PutNumber(l.numbers[i]) + seg.PutTimestamp(l.timestamps[i]) + timeout := rto / 2 + if timeout < 20 { + timeout = 20 + } + l.nextFlush[i] = current + timeout + + if seg.IsFull() { + l.writer.Write(seg) + seg.Release() + seg = NewAckSegment() + l.dirty = false + } + } + + if l.dirty || !seg.IsEmpty() { + for _, number := range l.flushCandidates { + if seg.IsFull() { + break + } + seg.PutNumber(number) + } + l.writer.Write(seg) + l.dirty = false + } + + seg.Release() +} + +type ReceivingWorker struct { + sync.RWMutex + conn *Connection + leftOver MultiBuffer + window *ReceivingWindow + acklist *AckList + nextNumber uint32 + windowSize uint32 +} + +func NewReceivingWorker(kcp *Connection) *ReceivingWorker { + worker := &ReceivingWorker{ + conn: kcp, + window: NewReceivingWindow(), + windowSize: kcp.Config.GetReceivingInFlightSize(), + } + worker.acklist = NewAckList(worker) + return worker +} + +func (w *ReceivingWorker) Release() { + w.Lock() + ReleaseMulti(w.leftOver) + w.leftOver = nil + w.Unlock() +} + +func (w *ReceivingWorker) ProcessSendingNext(number uint32) { + w.Lock() + defer w.Unlock() + + w.acklist.Clear(number) +} + +func (w *ReceivingWorker) ProcessSegment(seg *DataSegment) { + w.Lock() + defer w.Unlock() + + number := seg.Number + idx := number - w.nextNumber + if idx >= w.windowSize { + return + } + w.acklist.Clear(seg.SendingNext) + w.acklist.Add(number, seg.Timestamp) + + if !w.window.Set(seg.Number, seg) { + seg.Release() + } +} + +func (w *ReceivingWorker) ReadMultiBuffer() MultiBuffer { + if w.leftOver != nil { + mb := w.leftOver + w.leftOver = nil + return mb + } + + mb := make(MultiBuffer, 0, 32) + + w.Lock() + defer w.Unlock() + for { + seg := w.window.Remove(w.nextNumber) + if seg == nil { + break + } + w.nextNumber++ + mb = append(mb, seg.Detach()) + seg.Release() + } + + return mb +} + +func (w *ReceivingWorker) Read(b []byte) int { + mb := w.ReadMultiBuffer() + if mb.IsEmpty() { + return 0 + } + mb, nBytes := SplitBytes(mb, b) + if !mb.IsEmpty() { + w.leftOver = mb + } + return nBytes +} + +func (w *ReceivingWorker) IsDataAvailable() bool { + w.RLock() + defer w.RUnlock() + return w.window.Has(w.nextNumber) +} + +func (w *ReceivingWorker) NextNumber() uint32 { + w.RLock() + defer w.RUnlock() + + return w.nextNumber +} + +func (w *ReceivingWorker) Flush(current uint32) { + w.Lock() + defer w.Unlock() + + w.acklist.Flush(current, w.conn.roundTrip.Timeout()) +} + +func (w *ReceivingWorker) Write(seg Segment) error { + ackSeg := seg.(*AckSegment) + ackSeg.Conv = w.conn.meta.Conversation + ackSeg.ReceivingNext = w.nextNumber + ackSeg.ReceivingWindow = w.nextNumber + w.windowSize + ackSeg.Option = 0 + if w.conn.State() == StateReadyToClose { + ackSeg.Option = SegmentOptionClose + } + return w.conn.output.Write(ackSeg) +} + +func (*ReceivingWorker) CloseRead() { +} + +func (w *ReceivingWorker) UpdateNecessary() bool { + w.RLock() + defer w.RUnlock() + + return len(w.acklist.numbers) > 0 +} diff --git a/transport/v2raykcp/segment.go b/transport/v2raykcp/segment.go new file mode 100644 index 00000000..8b1b85e5 --- /dev/null +++ b/transport/v2raykcp/segment.go @@ -0,0 +1,312 @@ +package v2raykcp + +import ( + "encoding/binary" + + "github.com/sagernet/sing/common/buf" +) + +// Command is a KCP command that indicate the purpose of a Segment. +type Command byte + +const ( + // CommandACK indicates an AckSegment. + CommandACK Command = 0 + // CommandData indicates a DataSegment. + CommandData Command = 1 + // CommandTerminate indicates that peer terminates the connection. + CommandTerminate Command = 2 + // CommandPing indicates a ping. + CommandPing Command = 3 +) + +type SegmentOption byte + +const ( + SegmentOptionClose SegmentOption = 1 +) + +type Segment interface { + Release() + Conversation() uint16 + Command() Command + ByteSize() int32 + Serialize([]byte) + parse(conv uint16, cmd Command, opt SegmentOption, buf []byte) (bool, []byte) +} + +const ( + DataSegmentOverhead = 18 +) + +type DataSegment struct { + Conv uint16 + Option SegmentOption + Timestamp uint32 + Number uint32 + SendingNext uint32 + + payload *buf.Buffer + timeout uint32 + transmit uint32 +} + +func NewDataSegment() *DataSegment { + return new(DataSegment) +} + +func (s *DataSegment) parse(conv uint16, cmd Command, opt SegmentOption, data []byte) (bool, []byte) { + s.Conv = conv + s.Option = opt + if len(data) < 15 { + return false, nil + } + s.Timestamp = binary.BigEndian.Uint32(data) + data = data[4:] + + s.Number = binary.BigEndian.Uint32(data) + data = data[4:] + + s.SendingNext = binary.BigEndian.Uint32(data) + data = data[4:] + + dataLen := int(binary.BigEndian.Uint16(data)) + data = data[2:] + + if len(data) < dataLen { + return false, nil + } + // Ensure we have a payload buffer + if s.payload == nil { + s.payload = buf.New() + } + // Clear and write data + s.payload.Reset() + s.payload.Write(data[:dataLen]) + data = data[dataLen:] + + return true, data +} + +func (s *DataSegment) Conversation() uint16 { + return s.Conv +} + +func (*DataSegment) Command() Command { + return CommandData +} + +func (s *DataSegment) Detach() *buf.Buffer { + r := s.payload + s.payload = nil + return r +} + +func (s *DataSegment) Data() *buf.Buffer { + if s.payload == nil { + s.payload = buf.New() + } + return s.payload +} + +func (s *DataSegment) Serialize(b []byte) { + binary.BigEndian.PutUint16(b, s.Conv) + b[2] = byte(CommandData) + b[3] = byte(s.Option) + binary.BigEndian.PutUint32(b[4:], s.Timestamp) + binary.BigEndian.PutUint32(b[8:], s.Number) + binary.BigEndian.PutUint32(b[12:], s.SendingNext) + binary.BigEndian.PutUint16(b[16:], uint16(s.payload.Len())) + copy(b[18:], s.payload.Bytes()) +} + +func (s *DataSegment) ByteSize() int32 { + return int32(2 + 1 + 1 + 4 + 4 + 4 + 2 + s.payload.Len()) +} + +func (s *DataSegment) Release() { + if s.payload != nil { + s.payload.Release() + s.payload = nil + } +} + +type AckSegment struct { + Conv uint16 + Option SegmentOption + ReceivingWindow uint32 + ReceivingNext uint32 + Timestamp uint32 + NumberList []uint32 +} + +const ackNumberLimit = 128 + +func NewAckSegment() *AckSegment { + return &AckSegment{ + NumberList: make([]uint32, 0, ackNumberLimit), + } +} + +func (s *AckSegment) parse(conv uint16, cmd Command, opt SegmentOption, buf []byte) (bool, []byte) { + s.Conv = conv + s.Option = opt + if len(buf) < 13 { + return false, nil + } + + s.ReceivingWindow = binary.BigEndian.Uint32(buf) + buf = buf[4:] + + s.ReceivingNext = binary.BigEndian.Uint32(buf) + buf = buf[4:] + + s.Timestamp = binary.BigEndian.Uint32(buf) + buf = buf[4:] + + count := int(buf[0]) + buf = buf[1:] + + if len(buf) < count*4 { + return false, nil + } + for i := 0; i < count; i++ { + s.PutNumber(binary.BigEndian.Uint32(buf)) + buf = buf[4:] + } + + return true, buf +} + +func (s *AckSegment) Conversation() uint16 { + return s.Conv +} + +func (*AckSegment) Command() Command { + return CommandACK +} + +func (s *AckSegment) PutTimestamp(timestamp uint32) { + if timestamp-s.Timestamp < 0x7FFFFFFF { + s.Timestamp = timestamp + } +} + +func (s *AckSegment) PutNumber(number uint32) { + s.NumberList = append(s.NumberList, number) +} + +func (s *AckSegment) IsFull() bool { + return len(s.NumberList) == ackNumberLimit +} + +func (s *AckSegment) IsEmpty() bool { + return len(s.NumberList) == 0 +} + +func (s *AckSegment) ByteSize() int32 { + return 2 + 1 + 1 + 4 + 4 + 4 + 1 + int32(len(s.NumberList)*4) +} + +func (s *AckSegment) Serialize(b []byte) { + binary.BigEndian.PutUint16(b, s.Conv) + b[2] = byte(CommandACK) + b[3] = byte(s.Option) + binary.BigEndian.PutUint32(b[4:], s.ReceivingWindow) + binary.BigEndian.PutUint32(b[8:], s.ReceivingNext) + binary.BigEndian.PutUint32(b[12:], s.Timestamp) + b[16] = byte(len(s.NumberList)) + n := 17 + for _, number := range s.NumberList { + binary.BigEndian.PutUint32(b[n:], number) + n += 4 + } +} + +func (s *AckSegment) Release() {} + +type CmdOnlySegment struct { + Conv uint16 + Cmd Command + Option SegmentOption + SendingNext uint32 + ReceivingNext uint32 + PeerRTO uint32 +} + +func NewCmdOnlySegment() *CmdOnlySegment { + return new(CmdOnlySegment) +} + +func (s *CmdOnlySegment) parse(conv uint16, cmd Command, opt SegmentOption, buf []byte) (bool, []byte) { + s.Conv = conv + s.Cmd = cmd + s.Option = opt + + if len(buf) < 12 { + return false, nil + } + + s.SendingNext = binary.BigEndian.Uint32(buf) + buf = buf[4:] + + s.ReceivingNext = binary.BigEndian.Uint32(buf) + buf = buf[4:] + + s.PeerRTO = binary.BigEndian.Uint32(buf) + buf = buf[4:] + + return true, buf +} + +func (s *CmdOnlySegment) Conversation() uint16 { + return s.Conv +} + +func (s *CmdOnlySegment) Command() Command { + return s.Cmd +} + +func (*CmdOnlySegment) ByteSize() int32 { + return 2 + 1 + 1 + 4 + 4 + 4 +} + +func (s *CmdOnlySegment) Serialize(b []byte) { + binary.BigEndian.PutUint16(b, s.Conv) + b[2] = byte(s.Cmd) + b[3] = byte(s.Option) + binary.BigEndian.PutUint32(b[4:], s.SendingNext) + binary.BigEndian.PutUint32(b[8:], s.ReceivingNext) + binary.BigEndian.PutUint32(b[12:], s.PeerRTO) +} + +func (*CmdOnlySegment) Release() {} + +func ReadSegment(buf []byte) (Segment, []byte) { + if len(buf) < 4 { + return nil, nil + } + + conv := binary.BigEndian.Uint16(buf) + buf = buf[2:] + + cmd := Command(buf[0]) + opt := SegmentOption(buf[1]) + buf = buf[2:] + + var seg Segment + switch cmd { + case CommandData: + seg = NewDataSegment() + case CommandACK: + seg = NewAckSegment() + default: + seg = NewCmdOnlySegment() + } + + valid, extra := seg.parse(conv, cmd, opt, buf) + if !valid { + return nil, nil + } + return seg, extra +} diff --git a/transport/v2raykcp/sending.go b/transport/v2raykcp/sending.go new file mode 100644 index 00000000..c0e59953 --- /dev/null +++ b/transport/v2raykcp/sending.go @@ -0,0 +1,361 @@ +package v2raykcp + +import ( + "container/list" + "sync" + + "github.com/sagernet/sing/common/buf" +) + +type SendingWindow struct { + cache *list.List + totalInFlightSize uint32 + writer SegmentWriter + onPacketLoss func(uint32) +} + +func NewSendingWindow(writer SegmentWriter, onPacketLoss func(uint32)) *SendingWindow { + return &SendingWindow{ + cache: list.New(), + writer: writer, + onPacketLoss: onPacketLoss, + } +} + +func (sw *SendingWindow) Release() { + if sw == nil { + return + } + for sw.cache.Len() > 0 { + seg := sw.cache.Front().Value.(*DataSegment) + seg.Release() + sw.cache.Remove(sw.cache.Front()) + } +} + +func (sw *SendingWindow) Len() uint32 { + return uint32(sw.cache.Len()) +} + +func (sw *SendingWindow) IsEmpty() bool { + return sw.cache.Len() == 0 +} + +func (sw *SendingWindow) Push(number uint32, b *buf.Buffer) { + seg := NewDataSegment() + seg.Number = number + seg.payload = b + + sw.cache.PushBack(seg) +} + +func (sw *SendingWindow) FirstNumber() uint32 { + return sw.cache.Front().Value.(*DataSegment).Number +} + +func (sw *SendingWindow) Clear(una uint32) { + for !sw.IsEmpty() { + seg := sw.cache.Front().Value.(*DataSegment) + if seg.Number >= una { + break + } + seg.Release() + sw.cache.Remove(sw.cache.Front()) + } +} + +func (sw *SendingWindow) HandleFastAck(number uint32, rto uint32) { + if sw.IsEmpty() { + return + } + + sw.Visit(func(seg *DataSegment) bool { + if number == seg.Number || number-seg.Number > 0x7FFFFFFF { + return false + } + + if seg.transmit > 0 && seg.timeout > rto/3 { + seg.timeout -= rto / 3 + } + return true + }) +} + +func (sw *SendingWindow) Visit(visitor func(seg *DataSegment) bool) { + if sw.IsEmpty() { + return + } + + for e := sw.cache.Front(); e != nil; e = e.Next() { + seg := e.Value.(*DataSegment) + if !visitor(seg) { + break + } + } +} + +func (sw *SendingWindow) Flush(current uint32, rto uint32, maxInFlightSize uint32) { + if sw.IsEmpty() { + return + } + + var lost uint32 + var inFlightSize uint32 + + sw.Visit(func(segment *DataSegment) bool { + if current-segment.timeout >= 0x7FFFFFFF { + return true + } + if segment.transmit == 0 { + sw.totalInFlightSize++ + } else { + lost++ + } + segment.timeout = current + rto + + segment.Timestamp = current + segment.transmit++ + sw.writer.Write(segment) + inFlightSize++ + return inFlightSize < maxInFlightSize + }) + + if sw.onPacketLoss != nil && inFlightSize > 0 && sw.totalInFlightSize != 0 { + rate := lost * 100 / sw.totalInFlightSize + sw.onPacketLoss(rate) + } +} + +func (sw *SendingWindow) Remove(number uint32) bool { + if sw.IsEmpty() { + return false + } + + for e := sw.cache.Front(); e != nil; e = e.Next() { + seg := e.Value.(*DataSegment) + if seg.Number > number { + return false + } else if seg.Number == number { + if sw.totalInFlightSize > 0 { + sw.totalInFlightSize-- + } + seg.Release() + sw.cache.Remove(e) + return true + } + } + + return false +} + +type SendingWorker struct { + sync.RWMutex + conn *Connection + window *SendingWindow + firstUnacknowledged uint32 + nextNumber uint32 + remoteNextNumber uint32 + controlWindow uint32 + fastResend uint32 + windowSize uint32 + firstUnacknowledgedUpdated bool + closed bool +} + +func NewSendingWorker(kcp *Connection) *SendingWorker { + worker := &SendingWorker{ + conn: kcp, + fastResend: 2, + remoteNextNumber: 32, + controlWindow: kcp.Config.GetSendingInFlightSize(), + windowSize: kcp.Config.GetSendingBufferSize(), + } + worker.window = NewSendingWindow(worker, worker.OnPacketLoss) + return worker +} + +func (w *SendingWorker) Release() { + w.Lock() + w.window.Release() + w.closed = true + w.Unlock() +} + +func (w *SendingWorker) ProcessReceivingNext(nextNumber uint32) { + w.Lock() + defer w.Unlock() + + w.ProcessReceivingNextWithoutLock(nextNumber) +} + +func (w *SendingWorker) ProcessReceivingNextWithoutLock(nextNumber uint32) { + w.window.Clear(nextNumber) + w.FindFirstUnacknowledged() +} + +func (w *SendingWorker) FindFirstUnacknowledged() { + first := w.firstUnacknowledged + if !w.window.IsEmpty() { + w.firstUnacknowledged = w.window.FirstNumber() + } else { + w.firstUnacknowledged = w.nextNumber + } + if first != w.firstUnacknowledged { + w.firstUnacknowledgedUpdated = true + } +} + +func (w *SendingWorker) processAck(number uint32) bool { + if number-w.firstUnacknowledged > 0x7FFFFFFF || number-w.nextNumber < 0x7FFFFFFF { + return false + } + + removed := w.window.Remove(number) + if removed { + w.FindFirstUnacknowledged() + } + return removed +} + +func (w *SendingWorker) ProcessSegment(current uint32, seg *AckSegment, rto uint32) { + defer seg.Release() + + w.Lock() + defer w.Unlock() + + if w.closed { + return + } + + if w.remoteNextNumber < seg.ReceivingWindow { + w.remoteNextNumber = seg.ReceivingWindow + } + w.ProcessReceivingNextWithoutLock(seg.ReceivingNext) + + if seg.IsEmpty() { + return + } + + var maxack uint32 + var maxackRemoved bool + for _, number := range seg.NumberList { + removed := w.processAck(number) + if maxack < number { + maxack = number + maxackRemoved = removed + } + } + + if maxackRemoved { + w.window.HandleFastAck(maxack, rto) + if current-seg.Timestamp < 10000 { + w.conn.roundTrip.Update(current-seg.Timestamp, current) + } + } +} + +func (w *SendingWorker) Push(b *buf.Buffer) bool { + w.Lock() + defer w.Unlock() + + if w.closed { + return false + } + + if w.window.Len() > w.windowSize { + return false + } + + w.window.Push(w.nextNumber, b) + w.nextNumber++ + return true +} + +func (w *SendingWorker) Write(seg Segment) error { + dataSeg := seg.(*DataSegment) + + dataSeg.Conv = w.conn.meta.Conversation + dataSeg.SendingNext = w.firstUnacknowledged + dataSeg.Option = 0 + if w.conn.State() == StateReadyToClose { + dataSeg.Option = SegmentOptionClose + } + + return w.conn.output.Write(dataSeg) +} + +func (w *SendingWorker) OnPacketLoss(lossRate uint32) { + if !w.conn.Config.Congestion || w.conn.roundTrip.Timeout() == 0 { + return + } + + if lossRate >= 15 { + w.controlWindow = 3 * w.controlWindow / 4 + } else if lossRate <= 5 { + w.controlWindow += w.controlWindow / 4 + } + if w.controlWindow < 16 { + w.controlWindow = 16 + } + if w.controlWindow > 2*w.conn.Config.GetSendingInFlightSize() { + w.controlWindow = 2 * w.conn.Config.GetSendingInFlightSize() + } +} + +func (w *SendingWorker) Flush(current uint32) { + w.Lock() + + if w.closed { + w.Unlock() + return + } + + cwnd := w.conn.Config.GetSendingInFlightSize() + if cwnd > w.remoteNextNumber-w.firstUnacknowledged { + cwnd = w.remoteNextNumber - w.firstUnacknowledged + } + if w.conn.Config.Congestion && cwnd > w.controlWindow { + cwnd = w.controlWindow + } + + cwnd *= 20 + + if !w.window.IsEmpty() { + w.window.Flush(current, w.conn.roundTrip.Timeout(), cwnd) + w.firstUnacknowledgedUpdated = false + } + + updated := w.firstUnacknowledgedUpdated + w.firstUnacknowledgedUpdated = false + + w.Unlock() + + if updated { + w.conn.Ping(current, CommandPing) + } +} + +func (w *SendingWorker) CloseWrite() { + w.Lock() + defer w.Unlock() + + w.window.Clear(0xFFFFFFFF) +} + +func (w *SendingWorker) IsEmpty() bool { + w.RLock() + defer w.RUnlock() + + return w.window.IsEmpty() +} + +func (w *SendingWorker) UpdateNecessary() bool { + return !w.IsEmpty() +} + +func (w *SendingWorker) FirstUnacknowledged() uint32 { + w.RLock() + defer w.RUnlock() + + return w.firstUnacknowledged +} diff --git a/transport/v2raykcp/updater.go b/transport/v2raykcp/updater.go new file mode 100644 index 00000000..a5e28484 --- /dev/null +++ b/transport/v2raykcp/updater.go @@ -0,0 +1,58 @@ +package v2raykcp + +import ( + "sync/atomic" + "time" +) + +type Updater struct { + interval int64 + shouldContinue func() bool + shouldTerminate func() bool + updateFunc func() + notifier chan struct{} +} + +func NewUpdater(interval uint32, shouldContinue func() bool, shouldTerminate func() bool, updateFunc func()) *Updater { + u := &Updater{ + interval: int64(time.Duration(interval) * time.Millisecond), + shouldContinue: shouldContinue, + shouldTerminate: shouldTerminate, + updateFunc: updateFunc, + notifier: make(chan struct{}, 1), + } + return u +} + +func (u *Updater) WakeUp() { + select { + case u.notifier <- struct{}{}: + go u.run() + default: + } +} + +func (u *Updater) run() { + defer func() { + <-u.notifier + }() + + if u.shouldTerminate() { + return + } + ticker := time.NewTicker(u.Interval()) + defer ticker.Stop() + + for u.shouldContinue() { + u.updateFunc() + <-ticker.C + } +} + +func (u *Updater) Interval() time.Duration { + return time.Duration(atomic.LoadInt64(&u.interval)) +} + +func (u *Updater) SetInterval(d time.Duration) { + atomic.StoreInt64(&u.interval, int64(d)) +} diff --git a/transport/v2raywebsocket/server.go b/transport/v2raywebsocket/server.go index b54d760a..b3b2f065 100644 --- a/transport/v2raywebsocket/server.go +++ b/transport/v2raywebsocket/server.go @@ -115,7 +115,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { if len(earlyData) > 0 { conn = bufio.NewCachedConn(conn, buf.As(earlyData)) } - s.handler.NewConnectionEx(v2rayhttp.DupContext(request.Context()), conn, source, M.Socksaddr{}, nil) + s.handler.NewConnectionEx(v2rayhttp.HWIDContext(v2rayhttp.DupContext(request.Context()), request.Header), conn, source, M.Socksaddr{}, nil) } func (s *Server) invalidRequest(writer http.ResponseWriter, request *http.Request, statusCode int, err error) { diff --git a/transport/v2rayxhttp/server.go b/transport/v2rayxhttp/server.go index 1454539c..f06096d4 100644 --- a/transport/v2rayxhttp/server.go +++ b/transport/v2rayxhttp/server.go @@ -20,6 +20,7 @@ import ( 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/v2rayhttp" qtls "github.com/sagernet/sing-quic" // qtls "github.com/sagernet/sing-quic" @@ -265,7 +266,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { if sessionId != "" { // if not stream-one conn.reader = currentSession.uploadQueue } - s.handler.NewConnectionEx(request.Context(), &conn, sHttp.SourceAddress(request), M.Socksaddr{}, func(it error) {}) + s.handler.NewConnectionEx(v2rayhttp.HWIDContext(request.Context(), request.Header), &conn, sHttp.SourceAddress(request), M.Socksaddr{}, func(it error) {}) // "A ResponseWriter may not be used after [Handler.ServeHTTP] has returned." select { case <-request.Context().Done():