diff --git a/.gitmodules b/.gitmodules index 45ffb563..e69de29b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +0,0 @@ -[submodule "clients/apple"] - path = clients/apple - url = https://github.com/SagerNet/sing-box-for-apple.git -[submodule "clients/android"] - path = clients/android - url = https://github.com/SagerNet/sing-box-for-android.git diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 00000000..dcb55e4a --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,197 @@ +version: 2 +project_name: sing-box +builds: + - &template + id: main + 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_masque + - with_mtproxy + 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 + - 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_masque + - with_mtproxy + - 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 + - windows_arm64 + - darwin_amd64_v1 + - darwin_arm64 + mod_timestamp: '{{ .CommitTimestamp }}' + - id: legacy + <<: *template + tags: + - with_gvisor + - with_quic + - with_dhcp + - with_wireguard + - with_utls + - with_acme + - with_clash_api + - with_tailscale + - with_masque + - with_mtproxy + env: + - CGO_ENABLED=0 + targets: + - windows_amd64_v1 + - windows_386 + - id: android + <<: *template + env: + - CGO_ENABLED=1 + - GOTOOLCHAIN=local + overrides: + - goos: android + goarch: arm + goarm: 7 + env: + - CC=armv7a-linux-androideabi21-clang + - CXX=armv7a-linux-androideabi21-clang++ + - goos: android + goarch: arm64 + env: + - CC=aarch64-linux-android21-clang + - CXX=aarch64-linux-android21-clang++ + - goos: android + goarch: 386 + env: + - CC=i686-linux-android21-clang + - CXX=i686-linux-android21-clang++ + - goos: android + goarch: amd64 + goamd64: v1 + env: + - CC=x86_64-linux-android21-clang + - CXX=x86_64-linux-android21-clang++ + targets: + - android_arm_7 + - android_arm64 + - android_386 + - android_amd64 +archives: + - &template + id: archive + builds: + - main + - android + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + 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 }}' + - 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' +source: + enabled: false + name_template: '{{ .ProjectName }}-{{ .Version }}.source' + prefix_template: '{{ .ProjectName }}-{{ .Version }}/' +checksum: + disable: true + name_template: '{{ .ProjectName }}-{{ .Version }}.checksum' +signs: + - artifacts: checksum +release: + github: + owner: shtorm-7 + name: sing-box-extended + draft: true + prerelease: auto + mode: replace + ids: + - archive + - package + skip_upload: true diff --git a/Makefile b/Makefile index 9cb0fce9..90edb36e 100644 --- a/Makefile +++ b/Makefile @@ -70,14 +70,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 @@ -101,7 +97,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/README.md b/README.md index 9dd4c107..b284fbae 100644 --- a/README.md +++ b/README.md @@ -2,22 +2,47 @@ Sing-box with extended features. -## Features +## 🔥 Features -* Amnezia 1.5 -* WARP -* Tunneling -* Mieru -* XHTTP -* SDNS (DNSCrypt) -* Extended Wireguard options -* Unified delay +### 🌐 Protocols +- **WARP** +- **Masque** +- **MTProxy** +- **Mieru** +- **VPN** +- **Bond** +- **Fallback** -## Examples +### 🚦 Limiters +- **Bandwidth Limiter** +- **Connection Limiter** + +### 🛡 Encryption & Obfuscation +- **Amnezia 2.0** +- **VLESS encryption** + +### 🔄 Transports +- **mKCP** +- **XHTTP** + +### 🛠 Services +- **Admin Panel** +- **Manager** +- **Node Manager** + +### ⚙ Miscellaneous +- **Link parser** +- **SDNS (DNSCrypt)** +- **Extended WireGuard options** +- **Unified Delay** + +## 📚 Examples + +Configuration examples are available here: https://github.com/shtorm-7/sing-box-extended/tree/extended/examples -## Support the project +## Support the Project If you want to support the project, you can donate to the following addresses. @@ -42,7 +67,7 @@ bc1qqx97p8k4dchqkyd47s4vf74hrqdfnmhqvcja7x 0xAcc5919C22F2B3fAa0ec7E8BaD142da5B375FBF6 ``` -## License +## 📄 License ``` Copyright (C) 2022 by nekohasekai diff --git a/adapter/experimental.go b/adapter/experimental.go index 5409e163..d4b904ed 100644 --- a/adapter/experimental.go +++ b/adapter/experimental.go @@ -48,6 +48,7 @@ type CacheFile interface { RDRCStore StoreWARPConfig() bool + StoreMASQUEConfig() bool LoadMode() string StoreMode(mode string) error @@ -59,6 +60,10 @@ type CacheFile interface { SaveRuleSet(tag string, set *SavedBinary) error LoadWARPConfig(tag string) *SavedBinary SaveWARPConfig(tag string, set *SavedBinary) error + LoadMASQUEConfig(tag string) *SavedBinary + SaveMASQUEConfig(tag string, set *SavedBinary) error + LoadSubscription(tag string) *SavedBinary + SaveSubscription(tag string, sub *SavedBinary) error } type SavedBinary struct { diff --git a/adapter/inbound.go b/adapter/inbound.go index a3dfb933..4ffdcc58 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -30,6 +30,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 { @@ -41,16 +42,15 @@ type InboundManager interface { } type InboundContext struct { - Inbound string - InboundType string - IPVersion uint8 - Network string - Source M.Socksaddr - Destination M.Socksaddr - TunnelSource string - TunnelDestination string - User string - Outbound string + Inbound string + InboundType string + IPVersion uint8 + Network string + Source M.Socksaddr + Destination M.Socksaddr + Gateway *netip.Addr + User string + Outbound string // sniffer 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 91fb9c65..7816264f 100644 --- a/adapter/outbound.go +++ b/adapter/outbound.go @@ -35,6 +35,7 @@ type DirectRouteOutbound 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/adapter/provider.go b/adapter/provider.go new file mode 100644 index 00000000..0bb88860 --- /dev/null +++ b/adapter/provider.go @@ -0,0 +1,51 @@ +package adapter + +import ( + "context" + "time" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/x/list" +) + +type Provider interface { + Type() string + Tag() string + Outbounds() []Outbound + Outbound(tag string) (Outbound, bool) + UpdatedAt() time.Time + HealthCheck(ctx context.Context) (map[string]uint16, error) + RegisterCallback(callback ProviderUpdateCallback) *list.Element[ProviderUpdateCallback] + UnregisterCallback(element *list.Element[ProviderUpdateCallback]) +} + +type ProviderUpdater interface { + Update() error +} + +type ProviderSubscriptionInfo interface { + SubscriptionInfo() SubscriptionInfo +} + +type ProviderRegistry interface { + option.ProviderOptionsRegistry + CreateProvider(ctx context.Context, router Router, logFactory log.Factory, tag string, providerType string, options any) (Provider, error) +} + +type ProviderManager interface { + Lifecycle + Providers() []Provider + Get(tag string) (Provider, bool) + Remove(tag string) error + Create(ctx context.Context, router Router, logFactory log.Factory, tag string, providerType string, options any) error +} + +type SubscriptionInfo struct { + Upload int64 + Download int64 + Total int64 + Expire int64 +} + +type ProviderUpdateCallback = func(tag string) error diff --git a/adapter/provider/adapter.go b/adapter/provider/adapter.go new file mode 100644 index 00000000..3c55783e --- /dev/null +++ b/adapter/provider/adapter.go @@ -0,0 +1,267 @@ +package provider + +import ( + "context" + "reflect" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/urltest" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/batch" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/x/list" + "github.com/sagernet/sing/service" +) + +type Adapter struct { + ctx context.Context + outbound adapter.OutboundManager + router adapter.Router + logFactory log.Factory + logger log.ContextLogger + providerType string + providerTag string + outbounds []adapter.Outbound + outboundsByTag map[string]adapter.Outbound + ticker *time.Ticker + checking atomic.Bool + history adapter.URLTestHistoryStorage + callbackAccess sync.Mutex + callbacks list.List[adapter.ProviderUpdateCallback] + + link string + enabled bool + timeout time.Duration + interval time.Duration +} + +func NewAdapter(ctx context.Context, router adapter.Router, outbound adapter.OutboundManager, logFactory log.Factory, logger log.ContextLogger, providerTag string, providerType string, options option.ProviderHealthCheckOptions) Adapter { + timeout := time.Duration(options.Timeout) + if timeout == 0 { + timeout = 3 * time.Second + } + interval := time.Duration(options.Interval) + if interval == 0 { + interval = 10 * time.Minute + } + if interval < time.Minute { + interval = time.Minute + } + return Adapter{ + ctx: ctx, + outbound: outbound, + router: router, + logFactory: logFactory, + logger: logger, + providerType: providerType, + providerTag: providerTag, + + enabled: options.Enabled, + link: options.URL, + timeout: timeout, + interval: interval, + } +} + +func (a *Adapter) Start() error { + a.history = service.FromContext[adapter.URLTestHistoryStorage](a.ctx) + if a.history == nil { + if clashServer := service.FromContext[adapter.ClashServer](a.ctx); clashServer != nil { + a.history = clashServer.HistoryStorage() + } else { + a.history = urltest.NewHistoryStorage() + } + } + go a.loopCheck() + return nil +} + +func (a *Adapter) Type() string { + return a.providerType +} + +func (a *Adapter) Tag() string { + return a.providerTag +} + +func (a *Adapter) Outbounds() []adapter.Outbound { + return a.outbounds +} + +func (a *Adapter) Outbound(tag string) (adapter.Outbound, bool) { + if a.outboundsByTag == nil { + return nil, false + } + detour, ok := a.outboundsByTag[tag] + return detour, ok +} + +func (a *Adapter) UpdateOutbounds(oldOpts []option.Outbound, newOpts []option.Outbound) { + a.removeUseless(newOpts) + var ( + oldOptByTag = make(map[string]option.Outbound) + outbounds = make([]adapter.Outbound, 0, len(newOpts)) + outboundsByTag = make(map[string]adapter.Outbound) + ) + for _, opt := range oldOpts { + oldOptByTag[opt.Tag] = opt + } + for i, opt := range newOpts { + var tag string + if opt.Tag != "" { + tag = F.ToString(a.providerTag, "/", opt.Tag) + } else { + tag = F.ToString(a.providerTag, "/", i) + } + outbound, exist := a.outbound.Outbound(tag) + if !exist || !reflect.DeepEqual(opt, oldOptByTag[opt.Tag]) { + err := a.outbound.Create( + adapter.WithContext(a.ctx, &adapter.InboundContext{ + Outbound: tag, + }), + a.router, + a.logFactory.NewLogger(F.ToString("outbound/", opt.Type, "[", tag, "]")), + tag, + opt.Type, + opt.Options, + ) + if err != nil { + a.logger.Warn(err, " in ", tag, ", skip create this outbound") + continue + } + outbound, _ = a.outbound.Outbound(tag) + } + outbounds = append(outbounds, outbound) + outboundsByTag[tag] = outbound + } + if a.enabled && a.history != nil { + go a.HealthCheck(a.ctx) + } + a.outbounds = outbounds + a.outboundsByTag = outboundsByTag +} + +func (a *Adapter) HealthCheck(ctx context.Context) (map[string]uint16, error) { + if a.ticker != nil { + a.ticker.Reset(a.interval) + } + return a.healthcheck(ctx) +} + +func (a *Adapter) RegisterCallback(callback adapter.ProviderUpdateCallback) *list.Element[adapter.ProviderUpdateCallback] { + a.callbackAccess.Lock() + defer a.callbackAccess.Unlock() + return a.callbacks.PushBack(callback) +} + +func (a *Adapter) UnregisterCallback(element *list.Element[adapter.ProviderUpdateCallback]) { + a.callbackAccess.Lock() + defer a.callbackAccess.Unlock() + a.callbacks.Remove(element) +} + +func (a *Adapter) UpdateGroups() { + for element := a.callbacks.Front(); element != nil; element = element.Next() { + element.Value(a.providerTag) + } +} + +func (a *Adapter) Close() error { + if a.ticker != nil { + a.ticker.Stop() + } + outbounds := a.outbounds + a.outbounds = nil + var err error + for _, ob := range outbounds { + if err2 := a.outbound.Remove(ob.Tag()); err2 != nil { + err = E.Append(err, err2, func(err error) error { + return E.Cause(err, "close outbound [", ob.Tag(), "]") + }) + } + } + return err +} + +func (a *Adapter) loopCheck() { + if !a.enabled { + return + } + a.ticker = time.NewTicker(a.interval) + a.healthcheck(a.ctx) + for { + select { + case <-a.ctx.Done(): + return + case <-a.ticker.C: + a.healthcheck(a.ctx) + } + } +} + +func (a *Adapter) healthcheck(ctx context.Context) (map[string]uint16, error) { + result := make(map[string]uint16) + if a.checking.Swap(true) { + return result, nil + } + defer a.checking.Store(false) + b, _ := batch.New(ctx, batch.WithConcurrencyNum[any](10)) + var resultAccess sync.Mutex + checked := make(map[string]bool) + for _, detour := range a.outbounds { + tag := detour.Tag() + if checked[tag] { + continue + } + checked[tag] = true + b.Go(tag, func() (any, error) { + ctx, cancel := context.WithTimeout(a.ctx, a.timeout) + defer cancel() + t, err := urltest.URLTest(ctx, a.link, detour) + if err != nil { + a.logger.Debug("outbound ", tag, " unavailable: ", err) + a.history.DeleteURLTestHistory(tag) + } else { + a.logger.Debug("outbound ", tag, " available: ", t, "ms") + a.history.StoreURLTestHistory(tag, &adapter.URLTestHistory{ + Time: time.Now(), + Delay: t, + }) + resultAccess.Lock() + result[tag] = t + resultAccess.Unlock() + } + return nil, nil + }) + } + b.Wait() + return result, nil +} + +func (a *Adapter) removeUseless(newOpts []option.Outbound) { + if len(a.outbounds) == 0 { + return + } + exists := make(map[string]bool) + for i, opt := range newOpts { + var tag string + if opt.Tag != "" { + tag = F.ToString(a.providerTag, "/", opt.Tag) + } else { + tag = F.ToString(a.providerTag, "/", i) + } + exists[tag] = true + } + for _, opt := range a.outbounds { + if !exists[opt.Tag()] { + if err := a.outbound.Remove(opt.Tag()); err != nil { + a.logger.Error(err, "close outbound [", opt.Tag(), "]") + } + } + } +} diff --git a/adapter/provider/manager.go b/adapter/provider/manager.go new file mode 100644 index 00000000..563df8da --- /dev/null +++ b/adapter/provider/manager.go @@ -0,0 +1,157 @@ +package provider + +import ( + "context" + "io" + "os" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/taskmonitor" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" +) + +var _ adapter.ProviderManager = (*Manager)(nil) + +type Manager struct { + logger log.ContextLogger + registry adapter.ProviderRegistry + access sync.Mutex + started bool + stage adapter.StartStage + providers []adapter.Provider + providerByTag map[string]adapter.Provider + wg sync.WaitGroup +} + +func NewManager(logger logger.ContextLogger, registry adapter.ProviderRegistry) *Manager { + return &Manager{ + logger: logger, + registry: registry, + providerByTag: make(map[string]adapter.Provider), + } +} + +func (m *Manager) Initialize() { +} + +func (m *Manager) Start(stage adapter.StartStage) error { + m.access.Lock() + if m.started && m.stage >= stage { + panic("already started") + } + m.started = true + m.stage = stage + providers := m.providers + m.access.Unlock() + for _, provider := range providers { + err := adapter.LegacyStart(provider, stage) + if err != nil { + return E.Cause(err, stage, " provider/", provider.Type(), "[", provider.Tag(), "]") + } + } + return nil +} + +func (m *Manager) Close() error { + monitor := taskmonitor.New(m.logger, C.StopTimeout) + m.access.Lock() + if !m.started { + m.access.Unlock() + return nil + } + m.started = false + providers := m.providers + m.providers = nil + m.access.Unlock() + var err error + for _, provider := range providers { + if closer, isCloser := provider.(io.Closer); isCloser { + monitor.Start("close provider/", provider.Type(), "[", provider.Tag(), "]") + err = E.Append(err, closer.Close(), func(err error) error { + return E.Cause(err, "close provider/", provider.Type(), "[", provider.Tag(), "]") + }) + monitor.Finish() + } + } + return nil +} + +func (m *Manager) Providers() []adapter.Provider { + m.access.Lock() + defer m.access.Unlock() + return m.providers +} + +func (m *Manager) Get(tag string) (adapter.Provider, bool) { + m.access.Lock() + provider, found := m.providerByTag[tag] + m.access.Unlock() + return provider, found +} + +func (m *Manager) Remove(tag string) error { + m.access.Lock() + provider, found := m.providerByTag[tag] + if !found { + m.access.Unlock() + return os.ErrInvalid + } + delete(m.providerByTag, tag) + index := common.Index(m.providers, func(it adapter.Provider) bool { + return it == provider + }) + if index == -1 { + panic("invalid provider index") + } + m.providers = append(m.providers[:index], m.providers[index+1:]...) + started := m.started + m.access.Unlock() + if started { + return common.Close(provider) + } + return nil +} + +func (m *Manager) Create(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, providerType string, options any) error { + if tag == "" { + return os.ErrInvalid + } + + provider, err := m.registry.CreateProvider(ctx, router, logFactory, tag, providerType, options) + if err != nil { + return err + } + m.access.Lock() + defer m.access.Unlock() + if m.started { + for _, stage := range adapter.ListStartStages { + err = adapter.LegacyStart(provider, stage) + if err != nil { + return E.Cause(err, stage, " provider/", provider.Type(), "[", provider.Tag(), "]") + } + } + } + if existsProvider, loaded := m.providerByTag[tag]; loaded { + if m.started { + err = common.Close(existsProvider) + if err != nil { + return E.Cause(err, "close provider", provider.Type(), "[", existsProvider.Tag(), "]") + } + } + existsIndex := common.Index(m.providers, func(it adapter.Provider) bool { + return it == existsProvider + }) + if existsIndex == -1 { + panic("invalid provider index") + } + m.providers = append(m.providers[:existsIndex], m.providers[existsIndex+1:]...) + } + m.providers = append(m.providers, provider) + m.providerByTag[tag] = provider + return nil +} diff --git a/adapter/provider/registry.go b/adapter/provider/registry.go new file mode 100644 index 00000000..5a484754 --- /dev/null +++ b/adapter/provider/registry.go @@ -0,0 +1,72 @@ +package provider + +import ( + "context" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" +) + +type ConstructorFunc[T any] func(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options T) (adapter.Provider, error) + +func Register[Options any](registry *Registry, providerType string, constructor ConstructorFunc[Options]) { + registry.register(providerType, func() any { + return new(Options) + }, func(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, rawOptions any) (adapter.Provider, error) { + var options *Options + if rawOptions != nil { + options = rawOptions.(*Options) + } + return constructor(ctx, router, logFactory, tag, common.PtrValueOrDefault(options)) + }) +} + +var _ adapter.ProviderRegistry = (*Registry)(nil) + +type ( + optionsConstructorFunc func() any + constructorFunc func(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options any) (adapter.Provider, error) +) + +type Registry struct { + access sync.Mutex + optionsType map[string]optionsConstructorFunc + constructors map[string]constructorFunc +} + +func NewRegistry() *Registry { + return &Registry{ + optionsType: make(map[string]optionsConstructorFunc), + constructors: make(map[string]constructorFunc), + } +} + +func (r *Registry) CreateOptions(providerType string) (any, bool) { + r.access.Lock() + defer r.access.Unlock() + optionsConstructor, loaded := r.optionsType[providerType] + if !loaded { + return nil, false + } + return optionsConstructor(), true +} + +func (r *Registry) CreateProvider(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, providerType string, options any) (adapter.Provider, error) { + r.access.Lock() + defer r.access.Unlock() + constructor, loaded := r.constructors[providerType] + if !loaded { + return nil, E.New("provider type not found: '" + providerType + "'") + } + return constructor(ctx, router, logFactory, tag, options) +} + +func (r *Registry) register(providerType string, optionsConstructor optionsConstructorFunc, constructor constructorFunc) { + r.access.Lock() + defer r.access.Unlock() + r.optionsType[providerType] = optionsConstructor + r.constructors[providerType] = constructor +} diff --git a/box.go b/box.go index 3dfe7d6d..f99dbdb2 100644 --- a/box.go +++ b/box.go @@ -12,6 +12,7 @@ import ( "github.com/sagernet/sing-box/adapter/endpoint" "github.com/sagernet/sing-box/adapter/inbound" "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/adapter/provider" boxService "github.com/sagernet/sing-box/adapter/service" "github.com/sagernet/sing-box/common/certificate" "github.com/sagernet/sing-box/common/dialer" @@ -44,6 +45,7 @@ type Box struct { endpoint *endpoint.Manager inbound *inbound.Manager outbound *outbound.Manager + provider *provider.Manager service *boxService.Manager dnsTransport *dns.TransportManager dnsRouter *dns.Router @@ -64,6 +66,7 @@ func Context( inboundRegistry adapter.InboundRegistry, outboundRegistry adapter.OutboundRegistry, endpointRegistry adapter.EndpointRegistry, + providerRegistry adapter.ProviderRegistry, dnsTransportRegistry adapter.DNSTransportRegistry, serviceRegistry adapter.ServiceRegistry, ) context.Context { @@ -82,6 +85,11 @@ func Context( ctx = service.ContextWith[option.EndpointOptionsRegistry](ctx, endpointRegistry) ctx = service.ContextWith[adapter.EndpointRegistry](ctx, endpointRegistry) } + if service.FromContext[option.ProviderOptionsRegistry](ctx) == nil || + service.FromContext[adapter.ProviderRegistry](ctx) == nil { + ctx = service.ContextWith[option.ProviderOptionsRegistry](ctx, providerRegistry) + ctx = service.ContextWith[adapter.ProviderRegistry](ctx, providerRegistry) + } if service.FromContext[adapter.DNSTransportRegistry](ctx) == nil { ctx = service.ContextWith[option.DNSTransportOptionsRegistry](ctx, dnsTransportRegistry) ctx = service.ContextWith[adapter.DNSTransportRegistry](ctx, dnsTransportRegistry) @@ -104,6 +112,7 @@ func New(options Options) (*Box, error) { endpointRegistry := service.FromContext[adapter.EndpointRegistry](ctx) inboundRegistry := service.FromContext[adapter.InboundRegistry](ctx) outboundRegistry := service.FromContext[adapter.OutboundRegistry](ctx) + providerRegistry := service.FromContext[adapter.ProviderRegistry](ctx) dnsTransportRegistry := service.FromContext[adapter.DNSTransportRegistry](ctx) serviceRegistry := service.FromContext[adapter.ServiceRegistry](ctx) @@ -116,6 +125,9 @@ func New(options Options) (*Box, error) { if outboundRegistry == nil { return nil, E.New("missing outbound registry in context") } + if providerRegistry == nil { + return nil, E.New("missing provider registry in context") + } if dnsTransportRegistry == nil { return nil, E.New("missing DNS transport registry in context") } @@ -160,6 +172,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) @@ -180,11 +193,13 @@ func New(options Options) (*Box, error) { endpointManager := endpoint.NewManager(logFactory.NewLogger("endpoint"), endpointRegistry) inboundManager := inbound.NewManager(logFactory.NewLogger("inbound"), inboundRegistry, endpointManager) outboundManager := outbound.NewManager(logFactory.NewLogger("outbound"), outboundRegistry, endpointManager, routeOptions.Final) + providerManager := provider.NewManager(logFactory.NewLogger("provider"), providerRegistry) dnsTransportManager := dns.NewTransportManager(logFactory.NewLogger("dns/transport"), dnsTransportRegistry, outboundManager, dnsOptions.Final) serviceManager := boxService.NewManager(logFactory.NewLogger("service"), serviceRegistry) service.MustRegister[adapter.EndpointManager](ctx, endpointManager) service.MustRegister[adapter.InboundManager](ctx, inboundManager) service.MustRegister[adapter.OutboundManager](ctx, outboundManager) + service.MustRegister[adapter.ProviderManager](ctx, providerManager) service.MustRegister[adapter.DNSTransportManager](ctx, dnsTransportManager) service.MustRegister[adapter.ServiceManager](ctx, serviceManager) dnsRouter := dns.NewRouter(ctx, logFactory, dnsOptions) @@ -275,6 +290,10 @@ func New(options Options) (*Box, error) { return nil, E.Cause(err, "initialize inbound[", i, "]") } } + options.Outbounds = append(options.Outbounds, option.Outbound{ + Tag: "Compatible", + Type: C.TypeDirect, + }) for i, outboundOptions := range options.Outbounds { var tag string if outboundOptions.Tag != "" { @@ -301,6 +320,25 @@ func New(options Options) (*Box, error) { return nil, E.Cause(err, "initialize outbound[", i, "]") } } + for i, providerOptions := range options.Providers { + var tag string + if providerOptions.Tag != "" { + tag = providerOptions.Tag + } else { + tag = F.ToString(i) + } + err = providerManager.Create( + ctx, + router, + logFactory, + tag, + providerOptions.Type, + providerOptions.Options, + ) + if err != nil { + return nil, E.Cause(err, "initialize provider[", i, "]") + } + } for i, serviceOptions := range options.Services { var tag string if serviceOptions.Tag != "" { @@ -391,6 +429,7 @@ func New(options Options) (*Box, error) { endpoint: endpointManager, inbound: inboundManager, outbound: outboundManager, + provider: providerManager, dnsTransport: dnsTransportManager, service: serviceManager, dnsRouter: dnsRouter, @@ -454,11 +493,11 @@ func (s *Box) preStart() error { if err != nil { return err } - err = adapter.Start(s.logger, adapter.StartStateInitialize, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.inbound, s.endpoint, s.service) + err = adapter.Start(s.logger, adapter.StartStateInitialize, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.inbound, s.endpoint, s.provider, s.service) if err != nil { return err } - err = adapter.Start(s.logger, adapter.StartStateStart, s.outbound, s.dnsTransport, s.dnsRouter, s.network, s.connection, s.router) + err = adapter.Start(s.logger, adapter.StartStateStart, s.outbound, s.provider, s.dnsTransport, s.dnsRouter, s.network, s.connection, s.router) if err != nil { return err } @@ -478,7 +517,7 @@ func (s *Box) start() error { if err != nil { return err } - err = adapter.Start(s.logger, adapter.StartStatePostStart, s.outbound, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.inbound, s.endpoint, s.service) + err = adapter.Start(s.logger, adapter.StartStatePostStart, s.outbound, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.inbound, s.endpoint, s.provider, s.service) if err != nil { return err } @@ -486,7 +525,7 @@ func (s *Box) start() error { if err != nil { return err } - err = adapter.Start(s.logger, adapter.StartStateStarted, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.inbound, s.endpoint, s.service) + err = adapter.Start(s.logger, adapter.StartStateStarted, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.inbound, s.endpoint, s.provider, s.service) if err != nil { return err } @@ -512,6 +551,7 @@ func (s *Box) Close() error { {"service", s.service}, {"endpoint", s.endpoint}, {"inbound", s.inbound}, + {"provider", s.provider}, {"outbound", s.outbound}, {"router", s.router}, {"connection", s.connection}, diff --git a/cmd/sing-box/cmd_tools_fetch_http3.go b/cmd/sing-box/cmd_tools_fetch_http3.go index 3caa1e88..c11afc2d 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/cloudflare/api.go b/common/cloudflare/api.go index 2bd63343..85d33252 100644 --- a/common/cloudflare/api.go +++ b/common/cloudflare/api.go @@ -1,15 +1,12 @@ package cloudflare import ( + "bytes" "context" "encoding/json" "fmt" - "io" "net/http" - "strings" "time" - - "github.com/tidwall/gjson" ) type CloudflareApi struct { @@ -25,50 +22,93 @@ func NewCloudflareApi(opts ...CloudflareApiOption) *CloudflareApi { } func (api *CloudflareApi) CreateProfile(ctx context.Context, publicKey string) (*CloudflareProfile, error) { - request, err := http.NewRequest("POST", "https://api.cloudflareclient.com/v0i1909051800/reg", strings.NewReader( - fmt.Sprintf( - "{\"install_id\":\"\",\"tos\":\"%s\",\"key\":\"%s\",\"fcm_token\":\"\",\"type\":\"ios\",\"locale\":\"en_US\"}", - time.Now().Format("2006-01-02T15:04:05.000Z"), - publicKey, - ), - )) + serial, err := GenerateRandomAndroidSerial() + if err != nil { + return nil, fmt.Errorf("failed to generate serial: %v", err) + } + data := Registration{ + Key: publicKey, + InstallID: "", + FcmToken: "", + Tos: TimeAsCfString(time.Now()), + Model: "PC", + Serial: serial, + OsVersion: "", + KeyType: KeyTypeWg, + TunType: TunTypeWg, + Locale: "en-US", + } + jsonData, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("failed to marshal json: %v", err) + } + request, err := http.NewRequest("POST", ApiUrl+"/"+ApiVersion+"/reg", bytes.NewBuffer(jsonData)) if err != nil { return nil, err } + for k, v := range Headers { + request.Header.Set(k, v) + } response, err := api.client.Do(request.WithContext(ctx)) if err != nil { return nil, err } defer response.Body.Close() - if response.StatusCode != 200 { - return nil, fmt.Errorf("status code is not 200") - } - content, err := io.ReadAll(response.Body) - if err != nil { - return nil, err + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to register: %v", response.StatusCode) } profile := new(CloudflareProfile) - return profile, json.NewDecoder(strings.NewReader(gjson.Get(string(content), "result").Raw)).Decode(profile) + return profile, json.NewDecoder(response.Body).Decode(profile) } -func (api *CloudflareApi) GetProfile(ctx context.Context, authToken string, id string) (*CloudflareProfile, error) { - request, err := http.NewRequest("GET", "https://api.cloudflareclient.com/v0i1909051800/reg/"+id, nil) +func (api *CloudflareApi) EnrollKey(ctx context.Context, authToken string, id string, keyType, tunType, publicKey string) (*CloudflareProfile, error) { + deviceUpdate := DeviceUpdate{ + Name: "PC", + Key: publicKey, + KeyType: keyType, + TunType: tunType, + } + jsonData, err := json.Marshal(deviceUpdate) + if err != nil { + return nil, fmt.Errorf("failed to marshal json: %v", err) + } + request, err := http.NewRequest("PATCH", ApiUrl+"/"+ApiVersion+"/reg/"+id, bytes.NewBuffer(jsonData)) if err != nil { return nil, err } + for k, v := range Headers { + request.Header.Set(k, v) + } request.Header.Set("Authorization", "Bearer "+authToken) response, err := api.client.Do(request.WithContext(ctx)) if err != nil { return nil, err } defer response.Body.Close() - if response.StatusCode != 200 { - return nil, fmt.Errorf("status code is not 200") + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to enroll key: %v", response.StatusCode) } - content, err := io.ReadAll(response.Body) + profile := new(CloudflareProfile) + return profile, json.NewDecoder(response.Body).Decode(profile) +} + +func (api *CloudflareApi) GetProfile(ctx context.Context, authToken string, id string) (*CloudflareProfile, error) { + request, err := http.NewRequest("GET", ApiUrl+"/"+ApiVersion+"/reg/"+id, nil) if err != nil { return nil, err } + for k, v := range Headers { + request.Header.Set(k, v) + } + request.Header.Set("Authorization", "Bearer "+authToken) + response, err := api.client.Do(request.WithContext(ctx)) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get profile: %v", response.StatusCode) + } profile := new(CloudflareProfile) - return profile, json.NewDecoder(strings.NewReader(gjson.Get(string(content), "result").Raw)).Decode(profile) + return profile, json.NewDecoder(response.Body).Decode(profile) } diff --git a/common/cloudflare/constant.go b/common/cloudflare/constant.go new file mode 100644 index 00000000..e5108b07 --- /dev/null +++ b/common/cloudflare/constant.go @@ -0,0 +1,25 @@ +package cloudflare + +const ( + ApiUrl = "https://api.cloudflareclient.com" + ApiVersion = "v0a4471" + ConnectSNI = "consumer-masque.cloudflareclient.com" + // unused for now + ZeroTierSNI = "zt-masque.cloudflareclient.com" + ConnectURI = "https://cloudflareaccess.com" + DefaultModel = "PC" + KeyTypeWg = "curve25519" + TunTypeWg = "wireguard" + KeyTypeMasque = "secp256r1" + TunTypeMasque = "masque" + DefaultLocale = "en_US" + DefaultEndpointH2V4 = "162.159.198.2" + DefaultEndpointH2V6 = "" +) + +var Headers = map[string]string{ + "User-Agent": "WARP for Android", + "CF-Client-Version": "a-6.35-4471", + "Content-Type": "application/json; charset=UTF-8", + "Connection": "Keep-Alive", +} diff --git a/common/cloudflare/models.go b/common/cloudflare/models.go new file mode 100644 index 00000000..591b0338 --- /dev/null +++ b/common/cloudflare/models.go @@ -0,0 +1,132 @@ +package cloudflare + +import ( + "strings" +) + +type Registration struct { + Key string `json:"key"` + InstallID string `json:"install_id"` + FcmToken string `json:"fcm_token"` + Tos string `json:"tos"` + Model string `json:"model"` + Serial string `json:"serial_number"` + OsVersion string `json:"os_version"` + KeyType string `json:"key_type"` + TunType string `json:"tunnel_type"` + Locale string `json:"locale"` +} + +type CloudflareProfile struct { + ID string `json:"id"` + Type string `json:"type"` + Model string `json:"model"` + Name string `json:"name"` + Key string `json:"key"` + KeyType string `json:"key_type"` + TunType string `json:"tunnel_type"` + Account Account `json:"account"` + Config Config `json:"config"` + // WarpEnabled not set for ZeroTier + WarpEnabled bool `json:"warp_enabled,omitempty"` + // Waitlist not set for ZeroTier + Waitlist bool `json:"waitlist_enabled,omitempty"` + Created string `json:"created"` + Updated string `json:"updated"` + // Tos not set for ZeroTier + Tos string `json:"tos,omitempty"` + // Place not set for ZeroTier + Place int `json:"place,omitempty"` + Locale string `json:"locale"` + // Enabled not set for ZeroTier + Enabled bool `json:"enabled,omitempty"` + InstallID string `json:"install_id"` + // Token only set for /reg call + Token string `json:"token,omitempty"` + FcmToken string `json:"fcm_token"` + // SerialNumber not set for ZeroTier + SerialNumber string `json:"serial_number,omitempty"` + Policy Policy `json:"policy"` +} + +type Account struct { + ID string `json:"id"` + AccountType string `json:"account_type"` + // Created not set for ZeroTier + Created string `json:"created,omitempty"` + // Updated not set for ZeroTier + Updated string `json:"updated,omitempty"` + // Managed only set for ZeroTier + Managed string `json:"managed,omitempty"` + // Organization only set for ZeroTier + Organization string `json:"organization,omitempty"` + // PremiumData not set for ZeroTier + PremiumData int `json:"premium_data,omitempty"` + // Quota not set for ZeroTier + Quota int `json:"quota,omitempty"` + // WarpPlus not set for ZeroTier + WarpPlus bool `json:"warp_plus,omitempty"` + // ReferralCode not set for ZeroTier + ReferralCount int `json:"referral_count,omitempty"` + // ReferralRenewalCount not set for ZeroTier + ReferralRenewalCount int `json:"referral_renewal_countdown,omitempty"` + // Role not set for ZeroTier + Role string `json:"role,omitempty"` + // License not set for ZeroTier + License string `json:"license,omitempty"` +} + +type Config struct { + ClientID string `json:"client_id"` + Peers []Peer `json:"peers"` + Interface struct { + Addresses struct { + V4 string `json:"v4"` + V6 string `json:"v6"` + } `json:"addresses"` + } `json:"interface"` + Services struct { + HTTPProxy string `json:"http_proxy"` + } `json:"services"` +} + +type Peer struct { + PublicKey string `json:"public_key"` + Endpoint struct { + V4 string `json:"v4"` + V6 string `json:"v6"` + Host string `json:"host"` + Ports []int `json:"ports"` + } `json:"endpoint"` +} + +type Policy struct { + TunnelProtocol string `json:"tunnel_protocol"` +} + +type DeviceUpdate struct { + Key string `json:"key"` + KeyType string `json:"key_type"` + TunType string `json:"tunnel_type"` + Name string `json:"name,omitempty"` +} + +type APIError struct { + Result interface{} `json:"result"` + Success bool `json:"success"` + Errors []ErrorInfo `json:"errors"` + Messages []string `json:"messages"` +} + +type ErrorInfo struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (e *APIError) Error() string { + errors := make([]string, len(e.Errors)) + for i, err := range e.Errors { + errors[i] = err.Message + } + return strings.Join(errors, ",") +} diff --git a/common/cloudflare/option.go b/common/cloudflare/option.go index 929e91b7..1ba4f829 100644 --- a/common/cloudflare/option.go +++ b/common/cloudflare/option.go @@ -4,12 +4,14 @@ import ( "context" "net" "net/http" + "time" ) type CloudflareApiOption func(api *CloudflareApi) func WithDialContext(dialContext func(ctx context.Context, network, addr string) (net.Conn, error)) CloudflareApiOption { return func(api *CloudflareApi) { + api.client.Timeout = 30 * time.Second api.client.Transport = &http.Transport{ DialContext: dialContext, } diff --git a/common/cloudflare/profile.go b/common/cloudflare/profile.go deleted file mode 100644 index bc011e06..00000000 --- a/common/cloudflare/profile.go +++ /dev/null @@ -1,64 +0,0 @@ -package cloudflare - -import "time" - -type CloudflareProfile struct { - ID string `json:"id"` - Type string `json:"type"` - Name string `json:"name"` - Key string `json:"key"` - Account struct { - ID string `json:"id"` - AccountType string `json:"account_type"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` - PremiumData int `json:"premium_data"` - Quota int `json:"quota"` - Usage int `json:"usage"` - WARPPlus bool `json:"warp_plus"` - ReferralCount int `json:"referral_count"` - ReferralRenewalCountdown int `json:"referral_renewal_countdown"` - Role string `json:"role"` - License string `json:"license"` - TTL time.Time `json:"ttl"` - } `json:"account"` - Config struct { - ClientID string `json:"client_id"` - Interface struct { - Addresses struct { - V4 string `json:"v4"` - V6 string `json:"v6"` - } `json:"addresses"` - } `json:"interface"` - Peers []struct { - PublicKey string `json:"public_key"` - Endpoint struct { - V4 string `json:"v4"` - V6 string `json:"v6"` - Host string `json:"host"` - Ports []int `json:"ports"` - } `json:"endpoint"` - } `json:"peers"` - Services struct { - HTTPProxy string `json:"http_proxy"` - } `json:"services"` - Metrics struct { - Ping int `json:"ping"` - Report int `json:"report"` - } `json:"metrics"` - } `json:"config"` - Token string `json:"token"` - WARPEnabled bool `json:"warp_enabled"` - WaitlistEnabled bool `json:"waitlist_enabled"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` - Tos time.Time `json:"tos"` - Place int `json:"place"` - Locale string `json:"locale"` - Enabled bool `json:"enabled"` - InstallID string `json:"install_id"` - FcmToken string `json:"fcm_token"` - Policy struct { - TunnelProtocol string `json:"tunnel_protocol"` - } `json:"policy"` -} diff --git a/common/cloudflare/utils.go b/common/cloudflare/utils.go new file mode 100644 index 00000000..45aa1d41 --- /dev/null +++ b/common/cloudflare/utils.go @@ -0,0 +1,19 @@ +package cloudflare + +import ( + "crypto/rand" + "encoding/hex" + "time" +) + +func GenerateRandomAndroidSerial() (string, error) { + serial := make([]byte, 8) + if _, err := rand.Read(serial); err != nil { + return "", err + } + return hex.EncodeToString(serial), nil +} + +func TimeAsCfString(t time.Time) string { + return t.Format("2006-01-02T15:04:05.000-07:00") +} diff --git a/common/interrupt/context.go b/common/interrupt/context.go index 44726b2d..ba91601a 100644 --- a/common/interrupt/context.go +++ b/common/interrupt/context.go @@ -11,3 +11,13 @@ func ContextWithIsExternalConnection(ctx context.Context) context.Context { func IsExternalConnectionFromContext(ctx context.Context) bool { return ctx.Value(contextKeyIsExternalConnection{}) != nil } + +type contextKeyIsProviderConnection struct{} + +func ContextWithIsProviderConnection(ctx context.Context) context.Context { + return context.WithValue(ctx, contextKeyIsProviderConnection{}, true) +} + +func IsProviderConnectionFromContext(ctx context.Context) bool { + return ctx.Value(contextKeyIsProviderConnection{}) != nil +} diff --git a/common/interrupt/group.go b/common/interrupt/group.go index bd3fbb0a..ae9095f8 100644 --- a/common/interrupt/group.go +++ b/common/interrupt/group.go @@ -17,30 +17,31 @@ type Group struct { type groupConnItem struct { conn io.Closer isExternal bool + isProvider bool } func NewGroup() *Group { return &Group{} } -func (g *Group) NewConn(conn net.Conn, isExternal bool) net.Conn { +func (g *Group) NewConn(conn net.Conn, isExternal bool, isProvider bool) net.Conn { g.access.Lock() defer g.access.Unlock() - item := g.connections.PushBack(&groupConnItem{conn, isExternal}) + item := g.connections.PushBack(&groupConnItem{conn, isExternal, isProvider}) return &Conn{Conn: conn, group: g, element: item} } -func (g *Group) NewPacketConn(conn net.PacketConn, isExternal bool) net.PacketConn { +func (g *Group) NewPacketConn(conn net.PacketConn, isExternal bool, isProvider bool) net.PacketConn { g.access.Lock() defer g.access.Unlock() - item := g.connections.PushBack(&groupConnItem{conn, isExternal}) + item := g.connections.PushBack(&groupConnItem{conn, isExternal, isProvider}) return &PacketConn{PacketConn: conn, group: g, element: item} } -func (g *Group) NewSingPacketConn(conn N.PacketConn, isExternal bool) N.PacketConn { +func (g *Group) NewSingPacketConn(conn N.PacketConn, isExternal bool, isProvider bool) N.PacketConn { g.access.Lock() defer g.access.Unlock() - item := g.connections.PushBack(&groupConnItem{conn, isExternal}) + item := g.connections.PushBack(&groupConnItem{conn, isExternal, isProvider}) return &SingPacketConn{PacketConn: conn, group: g, element: item} } diff --git a/common/kmutex/mutex.go b/common/kmutex/mutex.go new file mode 100644 index 00000000..6e2e4ec7 --- /dev/null +++ b/common/kmutex/mutex.go @@ -0,0 +1,54 @@ +package kmutex + +import "sync" + +type Kmutex[T comparable] struct { + l sync.Locker + s map[T]*klock +} + +type klock struct { + cond *sync.Cond + ref uint64 +} + +func New[T comparable]() *Kmutex[T] { + l := sync.Mutex{} + return &Kmutex[T]{ + l: &l, + s: make(map[T]*klock), + } +} + +func (km *Kmutex[T]) Unlock(key T) { + km.l.Lock() + defer km.l.Unlock() + kl, ok := km.s[key] + if !ok || kl.ref == 0 { + panic("unlock of unlocked kmutex") + } + kl.ref-- + if kl.ref == 0 { + delete(km.s, key) + return + } + kl.cond.Signal() +} + +func (km *Kmutex[T]) Lock(key T) { + km.l.Lock() + defer km.l.Unlock() + for { + kl, ok := km.s[key] + if !ok { + km.s[key] = &klock{ + cond: sync.NewCond(km.l), + ref: 1, + } + return + } + kl.ref++ + kl.cond.Wait() + return + } +} diff --git a/common/kmutex/mutex_test.go b/common/kmutex/mutex_test.go new file mode 100644 index 00000000..6648442b --- /dev/null +++ b/common/kmutex/mutex_test.go @@ -0,0 +1,96 @@ +package kmutex + +import ( + "sync" + "testing" + "time" +) + +// Number of unique resources to access +const number = 100 + +func makeIds(count int) []int { + ids := make([]int, count) + for i := 0; i < count; i++ { + ids[i] = i + } + return ids +} + +func TestKmutex(t *testing.T) { + km := New[int]() + ids := makeIds(number) + resources := make([]int, number) + wg := sync.WaitGroup{} + + lc := make(chan int) + uc := make(chan int) + // Start 10n goroutines accessing n resources 10 times each + for i := 0; i < 10*number; i++ { + wg.Add(1) + go func(k int) { + for j := 0; j < 10; j++ { + lc <- k + km.Lock(ids[k]) + // read and write resource to check for race + resources[k] = resources[k] + 1 + km.Unlock(ids[k]) + uc <- k + } + wg.Done() + }(i % len(ids)) + } + + to := time.After(time.Second) + counts := make(map[int]int) + var lCount, ulCount int +loop: + for { + select { + case k := <-lc: + counts[k] = counts[k] + 1 + lCount++ + case k := <-uc: + counts[k] = counts[k] - 1 + ulCount++ + case <-to: + t.Fatal("timed out waiting for results") + break loop + } + expectCount := 100 * number + if lCount == expectCount && ulCount == expectCount { + // Have all results + break + } + } + for k, c := range counts { + if c != 0 { + t.Errorf("Key %d count != 0: %d\n", k, c) + } + } + + wg.Wait() +} + +func BenchmarkKmutex1000(b *testing.B) { + km := New[int]() + ids := makeIds(number) + resources := make([]int, number) + wg := sync.WaitGroup{} + + // Start 1000 goroutines accessing 100 resources N times each + b.ResetTimer() + for i := 0; i < 1000; i++ { + wg.Add(1) + go func(k int) { + for j := 0; j < b.N; j++ { + km.Lock(ids[k]) + // read and write resource to check for race + resources[k] = resources[k] + 1 + km.Unlock(ids[k]) + } + wg.Done() + }(i % len(ids)) + } + wg.Wait() +} diff --git a/common/migrate/source/raw.go b/common/migrate/source/raw.go new file mode 100644 index 00000000..ad496f56 --- /dev/null +++ b/common/migrate/source/raw.go @@ -0,0 +1,98 @@ +package source + +import ( + "io" + "io/fs" + "io/ioutil" + "strconv" + "strings" + + "github.com/golang-migrate/migrate/v4/source" + E "github.com/sagernet/sing/common/exceptions" +) + +type RawDriver struct { + migrations *source.Migrations + rawMigrations map[string]string +} + +func NewRawDriver(rawMigrations map[string]string) *RawDriver { + return &RawDriver{rawMigrations: rawMigrations} +} + +func (d *RawDriver) Init() error { + ms := source.NewMigrations() + for key := range d.rawMigrations { + m, err := source.DefaultParse(key) + if err != nil { + continue + } + if !ms.Append(m) { + return source.ErrDuplicateMigration{ + Migration: *m, + } + } + } + d.migrations = ms + return nil +} + +func (d *RawDriver) Open(url string) (source.Driver, error) { + return nil, E.New("open() cannot be called") +} + +func (d *RawDriver) Close() error { + return nil +} + +func (d *RawDriver) First() (version uint, err error) { + if version, ok := d.migrations.First(); ok { + return version, nil + } + return 0, &fs.PathError{ + Op: "first", + Err: fs.ErrNotExist, + } +} + +func (d *RawDriver) Prev(version uint) (prevVersion uint, err error) { + if version, ok := d.migrations.Prev(version); ok { + return version, nil + } + return 0, &fs.PathError{ + Op: "prev for version " + strconv.FormatUint(uint64(version), 10), + Err: fs.ErrNotExist, + } +} + +func (d *RawDriver) Next(version uint) (nextVersion uint, err error) { + if version, ok := d.migrations.Next(version); ok { + return version, nil + } + return 0, &fs.PathError{ + Op: "next for version " + strconv.FormatUint(uint64(version), 10), + Err: fs.ErrNotExist, + } +} + +func (d *RawDriver) ReadUp(version uint) (r io.ReadCloser, identifier string, err error) { + if m, ok := d.migrations.Up(version); ok { + body := ioutil.NopCloser(strings.NewReader(d.rawMigrations[m.Raw])) + return body, m.Identifier, nil + } + return nil, "", &fs.PathError{ + Op: "read up for version " + strconv.FormatUint(uint64(version), 10), + Err: fs.ErrNotExist, + } +} + +func (d *RawDriver) ReadDown(version uint) (r io.ReadCloser, identifier string, err error) { + if m, ok := d.migrations.Down(version); ok { + body := ioutil.NopCloser(strings.NewReader(d.rawMigrations[m.Raw])) + return body, m.Identifier, nil + } + return nil, "", &fs.PathError{ + Op: "read down for version " + strconv.FormatUint(uint64(version), 10), + Err: fs.ErrNotExist, + } +} 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/common/tls/masque_client.go b/common/tls/masque_client.go new file mode 100644 index 00000000..d4e23940 --- /dev/null +++ b/common/tls/masque_client.go @@ -0,0 +1,74 @@ +package tls + +import ( + "context" + "crypto/ecdsa" + "crypto/tls" + "crypto/x509" + "time" + + "github.com/sagernet/quic-go/http3" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" +) + +func NewMASQUEClient(ctx context.Context, logger logger.ContextLogger, serverName string, cert [][]byte, privateKey *ecdsa.PrivateKey, peerPublicKey *ecdsa.PublicKey, options option.MASQUEOutboundTLSOptions) (Config, error) { + var tlsConfig tls.Config + tlsConfig.ServerName = serverName + tlsConfig.InsecureSkipVerify = true + tlsConfig.NextProtos = []string{http3.NextProtoH3} + tlsConfig.Certificates = []tls.Certificate{ + { + Certificate: cert, + PrivateKey: privateKey, + }, + } + if options.CipherSuites != nil { + find: + for _, cipherSuite := range options.CipherSuites { + for _, tlsCipherSuite := range tls.CipherSuites() { + if cipherSuite == tlsCipherSuite.Name { + tlsConfig.CipherSuites = append(tlsConfig.CipherSuites, tlsCipherSuite.ID) + continue find + } + } + return nil, E.New("unknown cipher_suite: ", cipherSuite) + } + } + for _, curve := range options.CurvePreferences { + tlsConfig.CurvePreferences = append(tlsConfig.CurvePreferences, tls.CurveID(curve)) + } + if !options.Insecure { + tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { + if len(rawCerts) == 0 { + return nil + } + cert, err := x509.ParseCertificate(rawCerts[0]) + if err != nil { + return err + } + if _, ok := cert.PublicKey.(*ecdsa.PublicKey); !ok { + return x509.ErrUnsupportedAlgorithm + } + if !cert.PublicKey.(*ecdsa.PublicKey).Equal(peerPublicKey) { + return x509.CertificateInvalidError{Cert: cert, Reason: 10, Detail: "remote endpoint has a different public key than what we trust in config.json"} + } + return nil + } + } + var config Config = &STDClientConfig{ctx, &tlsConfig, options.Fragment, time.Duration(options.FragmentFallbackDelay), options.RecordFragment} + if options.KernelRx || options.KernelTx { + if !C.IsLinux { + return nil, E.New("kTLS is only supported on Linux") + } + config = &KTLSClientConfig{ + Config: config, + logger: logger, + kernelTx: options.KernelTx, + kernelRx: options.KernelRx, + } + } + return config, nil +} diff --git a/common/utils.go b/common/utils.go new file mode 100644 index 00000000..9b8a6ae6 --- /dev/null +++ b/common/utils.go @@ -0,0 +1,68 @@ +package common + +import ( + "encoding/base64" + "reflect" + "regexp" + "strconv" + "strings" + "time" + + "github.com/sagernet/sing/common/json/badoption" +) + +func StringToType[T any](str string) T { + var value T + v := reflect.ValueOf(&value).Elem() + switch any(value).(type) { + case badoption.Duration: + d, err := time.ParseDuration(str) + if err != nil { + v.SetInt(StringToType[int64](str)) + } else { + v.Set(reflect.ValueOf(d)) + } + return value + case badoption.HTTPHeader: + headers := badoption.HTTPHeader{} + reg := regexp.MustCompile(`^[ \t]*?(\S+?):[ \t]*?(\S+?)[ \t]*?$`) + for _, header := range strings.Split(str, "\n") { + result := reg.FindStringSubmatch(header) + if result != nil { + key := result[1] + headers[key] = strings.Split(result[2], ",") + } + } + v.Set(reflect.ValueOf(headers)) + return value + } + switch v.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + i, _ := strconv.ParseInt(str, 10, 64) + v.SetInt(i) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + i, _ := strconv.ParseUint(str, 10, 64) + v.SetUint(i) + case reflect.Float32, reflect.Float64: + f, _ := strconv.ParseFloat(str, 64) + v.SetFloat(f) + case reflect.Bool: + b, _ := strconv.ParseBool(str) + v.SetBool(b) + default: + panic("unsupported type") + } + return value +} + +func DecodeBase64URLSafe(content string) (string, error) { + s := strings.ReplaceAll(content, " ", "-") + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "+", "-") + s = strings.ReplaceAll(s, "=", "") + result, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return content, nil + } + return string(result), nil +} diff --git a/common/vision/hook.go b/common/vision/hook.go new file mode 100644 index 00000000..8a63cb7f --- /dev/null +++ b/common/vision/hook.go @@ -0,0 +1,25 @@ +package vision + +import ( + "context" + "net" +) + +type Hook func(net.Conn) + +type hookKey struct{} + +func WithHook(ctx context.Context, hook Hook) context.Context { + if hook == nil { + return ctx + } + return context.WithValue(ctx, hookKey{}, hook) +} + +func HookFromContext(ctx context.Context) (Hook, bool) { + if ctx == nil { + return nil, false + } + hook, ok := ctx.Value(hookKey{}).(Hook) + return hook, ok +} diff --git a/common/xray/cpuid/cpuid.go b/common/xray/cpuid/cpuid.go new file mode 100644 index 00000000..60938fe3 --- /dev/null +++ b/common/xray/cpuid/cpuid.go @@ -0,0 +1,18 @@ +package cpuid + +import ( + "runtime" + + "golang.org/x/sys/cpu" +) + +var ( + // Keep in sync with crypto/tls/cipher_suites.go. + hasGCMAsmAMD64 = cpu.X86.HasAES && cpu.X86.HasPCLMULQDQ + hasGCMAsmARM64 = cpu.ARM64.HasAES && cpu.ARM64.HasPMULL + hasGCMAsmS390X = cpu.S390X.HasAES && cpu.S390X.HasAESCBC && cpu.S390X.HasGHASH + hasGCMAsmPPC64 = runtime.GOARCH == "ppc64" || runtime.GOARCH == "ppc64le" + + // HasAESGCM indicates whether the CPU has AES-GCM hardware acceleration. + HasAESGCM = hasGCMAsmAMD64 || hasGCMAsmARM64 || hasGCMAsmS390X || hasGCMAsmPPC64 +) diff --git a/common/xray/json/badoption/range.go b/common/xray/json/badoption/range.go index fa32215b..28ed0896 100644 --- a/common/xray/json/badoption/range.go +++ b/common/xray/json/badoption/range.go @@ -71,6 +71,13 @@ func (c *Range) UnmarshalJSON(content []byte) error { return nil } +func (c *Range) String() string { + if c.From == c.To { + return strconv.FormatInt(int64(c.From), 10) + } + return fmt.Sprintf("%d-%d", c.From, c.To) +} + func (c Range) Rand() int32 { return int32(crypto.RandBetween(int64(c.From), int64(c.To))) } diff --git a/constant/provider.go b/constant/provider.go new file mode 100644 index 00000000..252b1af5 --- /dev/null +++ b/constant/provider.go @@ -0,0 +1,20 @@ +package constant + +const ( + ProviderTypeInline = "inline" + ProviderTypeLocal = "local" + ProviderTypeRemote = "remote" +) + +func ProviderDisplayName(providerType string) string { + switch providerType { + case ProviderTypeInline: + return "Inline" + case ProviderTypeLocal: + return "Local" + case ProviderTypeRemote: + return "Remote" + default: + return "Unknown" + } +} diff --git a/constant/proxy.go b/constant/proxy.go index 9094ef09..d8212baa 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -1,43 +1,56 @@ 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" - TypeCCM = "ccm" - TypeOCM = "ocm" - TypeOOMKiller = "oom-killer" + 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" + TypeMASQUE = "masque" + TypeMTProxy = "mtproxy" + TypeParser = "parser" + TypeHysteria = "hysteria" + TypeTor = "tor" + TypeSSH = "ssh" + TypeShadowTLS = "shadowtls" + TypeMieru = "mieru" + TypeAnyTLS = "anytls" + TypeShadowsocksR = "shadowsocksr" + TypeVLESS = "vless" + TypeTUIC = "tuic" + TypeHysteria2 = "hysteria2" + TypeBond = "bond" + TypeVPNServer = "vpn-server" + TypeVPNClient = "vpn-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" + TypeCCM = "ccm" + TypeOCM = "ocm" + TypeOOMKiller = "oom-killer" ) const ( + TypeFallback = "fallback" TypeSelector = "selector" TypeURLTest = "urltest" ) @@ -74,6 +87,12 @@ func ProxyDisplayName(proxyType string) string { return "WireGuard" case TypeWARP: return "WARP" + case TypeMASQUE: + return "MASQUE" + case TypeMTProxy: + return "MTProxy" + case TypeParser: + return "Parser" case TypeHysteria: return "Hysteria" case TypeTor: @@ -90,20 +109,24 @@ func ProxyDisplayName(proxyType string) string { return "TUIC" case TypeHysteria2: return "Hysteria2" + case TypeBond: + return "Bond" case TypeMieru: return "Mieru" case TypeAnyTLS: return "AnyTLS" + case TypeFallback: + return "Fallback" case TypeTailscale: return "Tailscale" case TypeSelector: return "Selector" case TypeURLTest: return "URLTest" - case TypeTunnelClient: - return "Tunnel Client" - case TypeTunnelServer: - return "Tunnel Server" + case TypeVPNClient: + return "VPN Client" + case TypeVPNServer: + return "VPN Server" default: return "Unknown" } 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/constant/warp.go b/constant/warp.go deleted file mode 100644 index 038ce346..00000000 --- a/constant/warp.go +++ /dev/null @@ -1,20 +0,0 @@ -package constant - -type WARPConfig struct { - PrivateKey string `json:"private_key"` - Interface struct { - Addresses struct { - V4 string `json:"v4"` - V6 string `json:"v6"` - } `json:"addresses"` - } `json:"interface"` - Peers []struct { - PublicKey string `json:"public_key"` - Endpoint struct { - V4 string `json:"v4"` - V6 string `json:"v6"` - Host string `json:"host"` - Ports []int `json:"ports"` - } `json:"endpoint"` - } `json:"peers"` -} diff --git a/docs/changelog.md b/docs/changelog.md index bd721377..f38e84de 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,8 +2,6 @@ icon: material/alert-decagram --- -<<<<<<< HEAD -======= #### 1.13.11 * Fix process searcher failure introduced in 1.13.9 @@ -63,7 +61,6 @@ from [SagerNet/go](https://github.com/SagerNet/go). See [OCM](/configuration/service/ocm). ->>>>>>> v1.13.11 #### 1.13.2 * Fixes and improvements diff --git a/examples/bandwidth_limiter/connection.json b/examples/bandwidth_limiter/connection.json new file mode 100644 index 00000000..fb39f24d --- /dev/null +++ b/examples/bandwidth_limiter/connection.json @@ -0,0 +1,57 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 443, + "transport": { + "type": "http" + }, + "users": [ + { + "name": "user1", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + }, + { + "name": "user2", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ] + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "bandwidth-limiter", + "tag": "bandwidth-limiter", + "strategy": "connection", + "mode": "duplex", // download, upload + "connection_type": "hwid", // mux, ip + "speed": "1MB", // 100KB, 1GB, etc. + "route": { // https://sing-box.sagernet.org/configuration/route/#structure + "rules": [], + "final": "direct" + } + } + ], + "route": { + "final": "bandwidth-limiter", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/bandwidth_limiter/global.json b/examples/bandwidth_limiter/global.json new file mode 100644 index 00000000..759d1f77 --- /dev/null +++ b/examples/bandwidth_limiter/global.json @@ -0,0 +1,56 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 443, + "transport": { + "type": "http" + }, + "users": [ + { + "name": "user1", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + }, + { + "name": "user2", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ] + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "bandwidth-limiter", + "tag": "bandwidth-limiter", + "strategy": "global", + "mode": "duplex", // download, upload + "speed": "1MB", // 100KB, 1GB, etc. + "route": { // https://sing-box.sagernet.org/configuration/route/#structure + "rules": [], + "final": "direct" + } + } + ], + "route": { + "final": "bandwidth-limiter", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/bandwidth_limiter/multi.json b/examples/bandwidth_limiter/multi.json new file mode 100644 index 00000000..f9665843 --- /dev/null +++ b/examples/bandwidth_limiter/multi.json @@ -0,0 +1,78 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 443, + "transport": { + "type": "http" + }, + "users": [ + { + "name": "user1", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + }, + { + "name": "user2", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ] + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "bandwidth-limiter", + "tag": "duplex-bandwidth-limiter", + "strategy": "global", + "mode": "duplex", + "speed": "5MB", + "route": { // https://sing-box.sagernet.org/configuration/route/#structure + "rules": [], + "final": "direct" + } + }, + { + "type": "bandwidth-limiter", + "tag": "upload-bandwidth-limiter", + "strategy": "global", + "mode": "upload", + "speed": "3MB", + "route": { // https://sing-box.sagernet.org/configuration/route/#structure + "rules": [], + "final": "duplex-bandwidth-limiter" + } + }, + { + "type": "bandwidth-limiter", + "tag": "download-bandwidth-limiter", + "strategy": "global", + "mode": "download", + "speed": "3MB", + "route": { // https://sing-box.sagernet.org/configuration/route/#structure + "rules": [], + "final": "upload-bandwidth-limiter" + } + } + ], + "route": { + "final": "download-bandwidth-limiter", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/bandwidth_limiter/users.json b/examples/bandwidth_limiter/users.json new file mode 100644 index 00000000..dbbef6c1 --- /dev/null +++ b/examples/bandwidth_limiter/users.json @@ -0,0 +1,70 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 443, + "transport": { + "type": "http" + }, + "users": [ + { + "name": "user1", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + }, + { + "name": "user2", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ] + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "bandwidth-limiter", + "tag": "bandwidth-limiter", + "strategy": "users", + "users": [ + { + "name": "user1", + "strategy": "connection", // global + "mode": "duplex", // download, upload + "connection_type": "hwid", // mux, ip + "speed": "5MB", // 100KB, 1GB, etc. + }, + { + "name": "user2", + "strategy": "connection", // global + "mode": "duplex", // download, upload + "connection_type": "hwid", // mux, ip + "speed": "1MB", // 100KB, 1GB, etc. + }, + ], + "route": { // https://sing-box.sagernet.org/configuration/route/#structure + "rules": [], + "final": "direct" + } + } + ], + "route": { + "final": "bandwidth-limiter", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/bond/client.json b/examples/bond/client.json new file mode 100644 index 00000000..adf5662b --- /dev/null +++ b/examples/bond/client.json @@ -0,0 +1,61 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "bond", + "tag": "bond-out", + "outbounds": [ // sum of download_ratio and upload_ratio must be 100 + { + "outbound": { + "type": "vless", + "server": "0.0.0.0", + "server_port": 443, + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "network": "tcp", + "bind_interface": "" + }, + "download_ratio": 50, + "upload_ratio": 50 + }, + { + "outbound": { + "type": "vless", + "server": "0.0.0.0", + "server_port": 444, + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "network": "tcp", + "bind_interface": "" + }, + "download_ratio": 50, + "upload_ratio": 50 + } + ] + } + ], + "route": { + "final": "bond-out", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/bond/client_multi.json b/examples/bond/client_multi.json new file mode 100644 index 00000000..f7a826ab --- /dev/null +++ b/examples/bond/client_multi.json @@ -0,0 +1,49 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "bond", + "tag": "bond-out", + "outbounds": [ // sum of download_ratio and upload_ratio must be 100 + { + "outbound": { + "type": "vless", + "server": "0.0.0.0", + "server_port": 443, + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "network": "tcp", + }, + "download_ratio": 20, + "upload_ratio": 20, + "count": 5 + } + ] + } + ], + "route": { + "final": "bond-out", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/bond/client_split.json b/examples/bond/client_split.json new file mode 100644 index 00000000..e1f0b577 --- /dev/null +++ b/examples/bond/client_split.json @@ -0,0 +1,61 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "bond", + "tag": "bond-out", + "outbounds": [ // sum of download_ratio and upload_ratio must be 100 + { + "outbound": { + "type": "vless", + "server": "0.0.0.0", + "server_port": 443, + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "network": "tcp", + "bind_interface": "" + }, + "download_ratio": 100, + "upload_ratio": 0 + }, + { + "outbound": { + "type": "vless", + "server": "0.0.0.0", + "server_port": 444, + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "network": "tcp", + "bind_interface": "" + }, + "download_ratio": 0, + "upload_ratio": 100 + } + ] + } + ], + "route": { + "final": "bond-out", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/bond/server.json b/examples/bond/server.json new file mode 100644 index 00000000..f624391d --- /dev/null +++ b/examples/bond/server.json @@ -0,0 +1,54 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "bond", + "tag": "bond-in", + "inbounds": [ + { + "type": "vless", + "listen": "0.0.0.0", + "listen_port": 443, + "users": [ + { + "name": "user", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ] + }, + { + "type": "vless", + "listen": "0.0.0.0", + "listen_port": 444, + "users": [ + { + "name": "user", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ] + } + ] + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + ], + "route": { + "final": "direct", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/connection_limiter/connection.json b/examples/connection_limiter/connection.json new file mode 100644 index 00000000..0b274ffd --- /dev/null +++ b/examples/connection_limiter/connection.json @@ -0,0 +1,56 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 443, + "transport": { + "type": "http" + }, + "users": [ + { + "name": "user1", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + }, + { + "name": "user2", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ], + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "connection-limiter", + "tag": "connection-limiter", + "strategy": "connection", + "connection_type": "hwid", // mux, ip + "count": 5, + "route": { // https://sing-box.sagernet.org/configuration/route/#structure + "rules": [], + "final": "direct" + } + } + ], + "route": { + "final": "connection-limiter", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/connection_limiter/users.json b/examples/connection_limiter/users.json new file mode 100644 index 00000000..7ade7e20 --- /dev/null +++ b/examples/connection_limiter/users.json @@ -0,0 +1,68 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 5000, + "transport": { + "type": "http" + }, + "users": [ + { + "name": "user1", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + }, + { + "name": "user2", + "uuid": "6c8c7ffc-a909-4699-af34-e9d9bcb3e6d6" + } + ], + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "connection-limiter", + "tag": "connection-limiter", + "strategy": "users", + "users": [ + { + "name": "user1", + "strategy": "connection", + "connection_type": "hwid", // mux, ip + "count": 5, + }, + { + "name": "user2", + "strategy": "connection", + "connection_type": "hwid", // mux, ip + "count": 1, + }, + ], + "route": { // https://sing-box.sagernet.org/configuration/route/#structure + "rules": [], + "final": "direct" + } + } + ], + "route": { + "final": "connection-limiter", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/fallback/client.json b/examples/fallback/client.json new file mode 100644 index 00000000..f67e3951 --- /dev/null +++ b/examples/fallback/client.json @@ -0,0 +1,61 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "vless", + "tag": "vless-1-out", + "server": "example1.com", + "server_port": 443, + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + }, + { + "type": "vless", + "tag": "vless-2-out", + "server": "example2.com", + "server_port": 443, + "uuid": "294fd6bc-4f89-43e7-9228-7900aba396af" + }, + { + "type": "vless", + "tag": "vless-3-out", + "server": "example3.com", + "server_port": 443, + "uuid": "257f20d0-294a-4f07-9f2c-9efee9a37400" + }, + { + "type": "fallback", + "tag": "fallback-out", + "outbounds": [ + "vless-1-out", + "vless-2-out", + "vless-3-out" + ] + } + ], + "route": { + "final": "fallback-out", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} \ No newline at end of file diff --git a/examples/manager/manager.json b/examples/manager/manager.json new file mode 100644 index 00000000..f13553f0 --- /dev/null +++ b/examples/manager/manager.json @@ -0,0 +1,62 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [], + "outbounds": [ + { + "type": "direct", + "tag": "direct-out" + } + ], + "route": { + "rules": [ + { + "protocol": "dns", + "action": "hijack-dns" + } + ], + "final": "direct-out" + }, + "services": [ + { + "type": "manager", + "tag": "my-manager", + "database": { + "driver": "postgresql", + "dsn": "postgresql://postgres:postgres@localhost:5432/manager?sslmode=disable" + } + }, + { // http://127.0.0.1:8000 + // Username: admin + // Password: admin + "type": "admin-panel", + "tag": "my-admin-panel", + "listen_port": 8000, + "manager": "my-manager", + "database": { + "driver": "postgresql", + "dsn": "postgresql://postgres:postgres@localhost:5432/adminpanel?sslmode=disable" + } + }, + { + "type": "node-manager-server", // for connecting nodes + "listen_port": 7000, + "manager": "my-manager", + "tls": { // https://sing-box.sagernet.org/configuration/shared/tls/#inbound + "enabled": true, + "server_name": "example.com", + "certificate_path": "/path/to/fullchain.pem", + "key_path": "/path/to/privkey.pem" + }, + } + ] +} diff --git a/examples/manager/node.json b/examples/manager/node.json new file mode 100644 index 00000000..6b487c81 --- /dev/null +++ b/examples/manager/node.json @@ -0,0 +1,77 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 443, + "transport": { + "type": "http" + } + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct-out" + }, + { + "type": "bandwidth-limiter", + "tag": "bandwidth-limiter", + "strategy": "manager", + "route": { + "final": "direct-out" + } + }, + { + "type": "connection-limiter", + "tag": "connection-limiter", + "strategy": "manager", + "route": { + "final": "bandwidth-limiter" + } + }, + ], + "route": { + "rules": [ + { + "protocol": "dns", + "action": "hijack-dns" + } + ], + "final": "connection-limiter" + }, + "services": [ + { + "type": "node", + "tag": "my-node", + "uuid": "e6eceb84-ad66-474b-8641-142499db7c6e", + "manager": "node-manager", + "inbounds": ["vless-in"], + "bandwidth_limiters": ["bandwidth-limiter"], + "connection_limiters": ["connection-limiter"], + }, + { + "type": "node-manager-client", + "tag": "node-manager", + "server": "example.com", + "server_port": 7000, + "tls": { // https://sing-box.sagernet.org/configuration/shared/tls/#outbound + "enabled": true, + "server_name": "example.com", + "alpn": "h2" // h3 for QUIC + }, + } + ] +} diff --git a/examples/masque/client.json b/examples/masque/client.json new file mode 100644 index 00000000..cd248287 --- /dev/null +++ b/examples/masque/client.json @@ -0,0 +1,58 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "masque", + "tag": "masque-out", + "use_http2": false, + "use_ipv6": false, + "profile": { + "detour": "direct", + // For getting existing MASQUE device profile, else sing-box will create new profile + "id": "", + "auth_token": "" + }, + "udp_timeout": "5m0s", + "udp_keepalive_period": "30s", + "udp_initial_packet_size": 0, + "reconnect_delay": "5s", + "tls": { // https://sing-box.sagernet.org/configuration/shared/tls/#fields + "insecure": false, + "cipher_suites": [], + "curve_preferences": [], + "fragment": false, + "fragment_fallback_delay": "", + "record_fragment": false, + "kernel_tx": false, + "kernel_rx": false, + } + // Dial Fields + } + ], + "route": { + "final": "masque-out", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} \ No newline at end of file diff --git a/examples/mkcp/client.json b/examples/mkcp/client.json new file mode 100644 index 00000000..f0d25ebb --- /dev/null +++ b/examples/mkcp/client.json @@ -0,0 +1,43 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "vless", + "tag": "vless-out", + "server": "example.com", + "server_port": 443, + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "packet_encoding": "", + "transport": { + "type": "mkcp", + "mtu": 1500 + } + } + ], + "route": { + "final": "vless-out", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} \ No newline at end of file diff --git a/examples/mkcp/server.json b/examples/mkcp/server.json new file mode 100644 index 00000000..0686a5d9 --- /dev/null +++ b/examples/mkcp/server.json @@ -0,0 +1,42 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 443, + "users": [ + { + "name": "user", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ], + "transport": { + "type": "mkcp", + "mtu": 1500 + } + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + } + ], + "route": { + "final": "direct", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/mtproxy/server.json b/examples/mtproxy/server.json new file mode 100644 index 00000000..85d4e9bd --- /dev/null +++ b/examples/mtproxy/server.json @@ -0,0 +1,83 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mtproxy", + // https://sing-box.sagernet.org/configuration/shared/listen/ + "listen": "0.0.0.0", + "listen_port": 3128, + "users": [ + { + "name": "user1", + "secret": "7hBO-dCS4EBzenlKbdLFxyNnb29nbGUuY29t" + } + ], + // concurrency is a size of the worker pool for connection management. + "concurrency": 8192, + // domain_fronting_port is a port we use to connect to a fronting domain. + "domain_fronting_port": 443, + // domain_fronting_ip is an IP address to use when connecting to the fronting + // domain instead of resolving the hostname from the secret via DNS. + "domain_fronting_ip": "", + // domain_fronting_proxy_protocol is used if communication between upstream + // endpoint and sing-box supports proxy protocol. + "domain_fronting_proxy_protocol": false, + // prefer_ip defines an IP connectivity preference. Valid values are: + // 'prefer-ipv4', 'prefer-ipv6', 'only-ipv4', 'only-ipv6'. + "prefer_ip": "prefer-ipv4", + // auto_update defines if it is required to auto update proxy list from + // Telegram instead of relying on a hardcoded list. + "auto_update": false, + // allow_fallback_on_unknown_dc defines how proxy behaves if unknown DC was + // requested. If this setting is set to false, then such connection will be + // rejected. Otherwise, proxy will chose any DC. + "allow_fallback_on_unknown_dc": false, + // tolerate_time_skewness is a time boundary that defines a time range where + // faketls timestamp is acceptable. + "tolerate_time_skewness": "", + // idle_timeout is a timeout for relay when we have to break a stream. + "idle_timeout": "5m", + // handshake_timeout is a timeout during which all handshake ceremonies must + // be completed, otherwise this process will be aborted + "handshake_timeout": "10s", + // doppelganger_urls is a list of URLs that should be crawled by + // sing-box to calculate parameters for statistical distribution of a + // traffic for fronting domains. + "doppelganger_urls": [], + // doppelganger_per_raid defines how many time each URL from + // doppelganger_urls list should be crawled per raid. + "doppelganger_per_raid": 10, + // doppelganger_each defines a time period between each raid. We recommend + // to use hours here. + "doppelganger_each": "6h", + // doppelganger_drs defines if TLS Dynamic Record Sizing is active. + "doppelganger_drs": false, + // throttle_max_connections is the total connection limit. + "throttle_max_connections": 0, + // throttle_check_interval is how often the throttle recomputes per-user + // caps. + "throttle_check_interval": "5s" + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + } + ], + "route": { + "final": "direct", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/parser/client.json b/examples/parser/client.json new file mode 100644 index 00000000..5af54473 --- /dev/null +++ b/examples/parser/client.json @@ -0,0 +1,37 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "parser", + "tag": "vless-out", + // Supported protocols: hysteria, hysteria2, shadowsocks, trojan, tuic, vless, vmess + "link": "vless://b5e41c8c-c437-4689-b863-76208a3efb4b@0.0.0.0:443?..." + } + ], + "route": { + "final": "vless-out", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} \ No newline at end of file diff --git a/examples/tunnel/client1-server-client2/server.json b/examples/tunnel/client1-server-client2/server.json deleted file mode 100644 index 9110ca62..00000000 --- a/examples/tunnel/client1-server-client2/server.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "log": { - "level": "error" - }, - "dns": { - "servers": [ - { - "type": "local", - "tag": "default" - } - ] - }, - "endpoints": [ - { - "type": "tunnel_server", - "tag": "tunnel", - "uuid": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13", - "users": [ - { - "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", - "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe" - }, - { - "uuid": "487f6073-3300-4819-a07d-39652e45fb4d", - "key": "3d74d616-2502-4c17-9cc3-92c366550f4f" - } - ], - "inbound": { - "type": "vless", - "tag": "vless-in", - "listen": "0.0.0.0", - "listen_port": 8000, - "users": [ - { - "name": "vless", - "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" - } - ] - } - } - ], - "outbounds": [ - { - "type": "direct", - "tag": "direct-out" - } - ], - "route": { - "rules": [ - { - "tunnel_source": [ - "9b65b7e1-04c8-4717-8f45-2aa61fd25937", - "487f6073-3300-4819-a07d-39652e45fb4d" - ], - "tunnel_destination": [ - "9b65b7e1-04c8-4717-8f45-2aa61fd25937", - "487f6073-3300-4819-a07d-39652e45fb4d" - ], - "outbound": "tunnel" - } - ], - "final": "direct-out", - "default_domain_resolver": "default", - "auto_detect_interface": true - } -} \ No newline at end of file diff --git a/examples/tunnel/proxy_client-server-tunnel_client/server.json b/examples/tunnel/proxy_client-server-tunnel_client/server.json deleted file mode 100644 index 2984260e..00000000 --- a/examples/tunnel/proxy_client-server-tunnel_client/server.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "log": { - "level": "error" - }, - "dns": { - "servers": [ - { - "type": "local", - "tag": "default" - } - ] - }, - "endpoints": [ - { - "type": "tunnel_server", - "tag": "tunnel", - "uuid": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13", - "users": [ - { - "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", - "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe" - } - ], - "inbound": { - "type": "vless", - "tag": "vless-in", - "listen": "0.0.0.0", - "listen_port": 8000, - "users": [ - { - "name": "vless", - "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" - } - ] - } - } - ], - "outbounds": [ - { - "type": "direct", - "tag": "direct-out" - } - ], - "route": { - "rules": [ - { - "inbound": "vless-in", - "outbound": "tunnel", - "override_tunnel_destination": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" - } - ], - "final": "direct-out", - "default_domain_resolver": "default", - "auto_detect_interface": true - } -} \ No newline at end of file diff --git a/examples/vless_encryption/client.json b/examples/vless_encryption/client.json new file mode 100644 index 00000000..9c77c9a4 --- /dev/null +++ b/examples/vless_encryption/client.json @@ -0,0 +1,40 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "vless", + "tag": "vless-out", + "server": "example.com", + "server_port": 443, + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "encryption": "", // xray vlessenc + "packet_encoding": "" + } + ], + "route": { + "final": "vless-out", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} \ No newline at end of file diff --git a/examples/vless_encryption/server.json b/examples/vless_encryption/server.json new file mode 100644 index 00000000..6ac470c2 --- /dev/null +++ b/examples/vless_encryption/server.json @@ -0,0 +1,39 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 443, + "decryption": "", // xray vlessenc + "users": [ + { + "name": "user", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ], + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + } + ], + "route": { + "final": "direct", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/tunnel/client-server/client.json b/examples/vpn/client-server/client.json similarity index 74% rename from examples/tunnel/client-server/client.json rename to examples/vpn/client-server/client.json index c2571a47..81508ebf 100644 --- a/examples/tunnel/client-server/client.json +++ b/examples/vpn/client-server/client.json @@ -12,9 +12,9 @@ }, "endpoints": [ { - "type": "tunnel_client", - "tag": "tunnel", - "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "type": "vpn-client", + "tag": "vpn", + "address": "10.0.0.2", "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe", "outbound": { "type": "vless", @@ -37,17 +37,16 @@ { "type": "direct", "tag": "direct-out" - }, - { - "type": "dns", - "tag": "dns-out" } ], "route": { "rules": [ { - "outbound": "tunnel", - "override_tunnel_destination": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13" + "protocol": "dns", + "action": "hijack-dns" + }, + { + "outbound": "vpn", } ], "final": "direct-out", diff --git a/examples/vpn/client-server/server.json b/examples/vpn/client-server/server.json new file mode 100644 index 00000000..1c68ed46 --- /dev/null +++ b/examples/vpn/client-server/server.json @@ -0,0 +1,51 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "endpoints": [ + { + "type": "vpn-server", + "tag": "vpn", + "address": "10.0.0.1", + "users": [ + { + "address": "10.0.0.2", + "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe" + } + ], + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 8000, + "users": [ + { + "name": "vless", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ] + } + ] + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct-out" + } + ], + "route": { + "final": "direct-out", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} \ No newline at end of file diff --git a/examples/tunnel/client1-server-client2/client1.json b/examples/vpn/client1-server-client2/client1.json similarity index 77% rename from examples/tunnel/client1-server-client2/client1.json rename to examples/vpn/client1-server-client2/client1.json index 09c73242..70600225 100644 --- a/examples/tunnel/client1-server-client2/client1.json +++ b/examples/vpn/client1-server-client2/client1.json @@ -12,9 +12,9 @@ }, "endpoints": [ { - "type": "tunnel_client", - "tag": "tunnel", - "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "type": "vpn-client", + "tag": "vpn", + "address": "10.0.0.2", "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe", "outbound": { "type": "vless", @@ -42,8 +42,8 @@ "route": { "rules": [ { - "outbound": "tunnel", - "override_tunnel_destination": "487f6073-3300-4819-a07d-39652e45fb4d" + "outbound": "vpn", + "override_gateway": "10.0.0.3" } ], "final": "direct-out", diff --git a/examples/tunnel/client1-server-client2/client2.json b/examples/vpn/client1-server-client2/client2.json similarity index 85% rename from examples/tunnel/client1-server-client2/client2.json rename to examples/vpn/client1-server-client2/client2.json index 7dda19a3..907f638e 100644 --- a/examples/tunnel/client1-server-client2/client2.json +++ b/examples/vpn/client1-server-client2/client2.json @@ -12,9 +12,9 @@ }, "endpoints": [ { - "type": "tunnel_client", - "tag": "tunnel", - "uuid": "487f6073-3300-4819-a07d-39652e45fb4d", + "type": "vpn-client", + "tag": "vpn", + "address": "10.0.0.3", "key": "3d74d616-2502-4c17-9cc3-92c366550f4f", "outbound": { "type": "vless", diff --git a/examples/vpn/client1-server-client2/server.json b/examples/vpn/client1-server-client2/server.json new file mode 100644 index 00000000..bc6aafb5 --- /dev/null +++ b/examples/vpn/client1-server-client2/server.json @@ -0,0 +1,61 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "endpoints": [ + { + "type": "vpn-server", + "tag": "vpn", + "address": "10.0.0.1", + "users": [ + { + "address": "10.0.0.2", + "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe" + }, + { + "address": "10.0.0.3", + "key": "3d74d616-2502-4c17-9cc3-92c366550f4f" + } + ], + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 8000, + "users": [ + { + "name": "vless", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ] + } + ] + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct-out" + } + ], + "route": { + "rules": [ + { + "source_ip_cidr": "10.0.0.0/24", + "outbound": "vpn" + } + ], + "final": "direct-out", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} \ No newline at end of file diff --git a/examples/tunnel/proxy_client-server-tunnel_client/proxy_client.json b/examples/vpn/proxy_client-server-tunnel_client/proxy_client.json similarity index 91% rename from examples/tunnel/proxy_client-server-tunnel_client/proxy_client.json rename to examples/vpn/proxy_client-server-tunnel_client/proxy_client.json index 390b73b3..eabb3192 100644 --- a/examples/tunnel/proxy_client-server-tunnel_client/proxy_client.json +++ b/examples/vpn/proxy_client-server-tunnel_client/proxy_client.json @@ -29,10 +29,6 @@ "server_port": 8000, "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", "network": "tcp" - }, - { - "type": "dns", - "tag": "dns-out" } ], "route": { diff --git a/examples/tunnel/client-server/server.json b/examples/vpn/proxy_client-server-tunnel_client/server.json similarity index 75% rename from examples/tunnel/client-server/server.json rename to examples/vpn/proxy_client-server-tunnel_client/server.json index 0a74beb8..629eb072 100644 --- a/examples/tunnel/client-server/server.json +++ b/examples/vpn/proxy_client-server-tunnel_client/server.json @@ -12,12 +12,12 @@ }, "endpoints": [ { - "type": "tunnel_server", - "tag": "tunnel", - "uuid": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13", + "type": "vpn-server", + "tag": "vpn", + "address": "10.0.0.1", "users": [ { - "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "address": "10.0.0.2", "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe" } ], @@ -42,6 +42,13 @@ } ], "route": { + "rules": [ + { + "inbound": "vless-in", + "outbound": "vpn", + "override_gateway": "10.0.0.2" + } + ], "final": "direct-out", "default_domain_resolver": "default", "auto_detect_interface": true diff --git a/examples/tunnel/proxy_client-server-tunnel_client/tunnel_client.json b/examples/vpn/proxy_client-server-tunnel_client/tunnel_client.json similarity index 85% rename from examples/tunnel/proxy_client-server-tunnel_client/tunnel_client.json rename to examples/vpn/proxy_client-server-tunnel_client/tunnel_client.json index 48316a46..6df5d666 100644 --- a/examples/tunnel/proxy_client-server-tunnel_client/tunnel_client.json +++ b/examples/vpn/proxy_client-server-tunnel_client/tunnel_client.json @@ -12,9 +12,9 @@ }, "endpoints": [ { - "type": "tunnel_client", - "tag": "tunnel", - "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "type": "vpn-client", + "tag": "vpn", + "address": "10.0.0.2", "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe", "outbound": { "type": "vless", diff --git a/examples/tunnel/server-client/client.json b/examples/vpn/server-client/client.json similarity index 82% rename from examples/tunnel/server-client/client.json rename to examples/vpn/server-client/client.json index 48316a46..95f369fc 100644 --- a/examples/tunnel/server-client/client.json +++ b/examples/vpn/server-client/client.json @@ -1,6 +1,6 @@ { "log": { - "level": "error" + "level": "info" }, "dns": { "servers": [ @@ -12,9 +12,9 @@ }, "endpoints": [ { - "type": "tunnel_client", - "tag": "tunnel", - "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "type": "vpn-client", + "tag": "vpn", + "address": "10.0.0.2", "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe", "outbound": { "type": "vless", diff --git a/examples/tunnel/server-client/server.json b/examples/vpn/server-client/server.json similarity index 72% rename from examples/tunnel/server-client/server.json rename to examples/vpn/server-client/server.json index ae005cad..c02f480a 100644 --- a/examples/tunnel/server-client/server.json +++ b/examples/vpn/server-client/server.json @@ -1,6 +1,6 @@ { "log": { - "level": "error" + "level": "info" }, "dns": { "servers": [ @@ -12,12 +12,12 @@ }, "endpoints": [ { - "type": "tunnel_server", - "tag": "tunnel", - "uuid": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13", + "type": "vpn-server", + "tag": "vpn", + "address": "10.0.0.1", "users": [ { - "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "address": "10.0.0.2", "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe" } ], @@ -39,7 +39,7 @@ { "type": "mixed", "tag": "mixed-in", - "listen_port": 7897 + "listen_port": 10000 } ], "outbounds": [ @@ -51,8 +51,8 @@ "route": { "rules": [ { - "outbound": "tunnel", - "override_tunnel_destination": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + "outbound": "vpn", + "override_gateway": "10.0.0.2" } ], "final": "direct-out", diff --git a/experimental/cachefile/cache.go b/experimental/cachefile/cache.go index 03ef055f..24eb5112 100644 --- a/experimental/cachefile/cache.go +++ b/experimental/cachefile/cache.go @@ -44,6 +44,7 @@ type CacheFile struct { storeFakeIP bool storeRDRC bool storeWARPConfig bool + storeMASQUEConfig bool rdrcTimeout time.Duration DB *bbolt.DB resetAccess sync.Mutex @@ -82,17 +83,18 @@ func New(ctx context.Context, options option.CacheFileOptions) *CacheFile { } } return &CacheFile{ - ctx: ctx, - path: filemanager.BasePath(ctx, path), - cacheID: cacheIDBytes, - storeFakeIP: options.StoreFakeIP, - storeRDRC: options.StoreRDRC, - storeWARPConfig: options.StoreWARPConfig, - rdrcTimeout: rdrcTimeout, - saveDomain: make(map[netip.Addr]string), - saveAddress4: make(map[string]netip.Addr), - saveAddress6: make(map[string]netip.Addr), - saveRDRC: make(map[saveRDRCCacheKey]bool), + ctx: ctx, + path: filemanager.BasePath(ctx, path), + cacheID: cacheIDBytes, + storeFakeIP: options.StoreFakeIP, + storeRDRC: options.StoreRDRC, + storeWARPConfig: options.StoreWARPConfig, + storeMASQUEConfig: options.StoreMASQUEConfig, + rdrcTimeout: rdrcTimeout, + saveDomain: make(map[netip.Addr]string), + saveAddress4: make(map[string]netip.Addr), + saveAddress6: make(map[string]netip.Addr), + saveRDRC: make(map[saveRDRCCacheKey]bool), } } @@ -366,6 +368,10 @@ func (c *CacheFile) StoreWARPConfig() bool { return c.storeWARPConfig } +func (c *CacheFile) StoreMASQUEConfig() bool { + return c.storeMASQUEConfig +} + func (c *CacheFile) LoadWARPConfig(tag string) *adapter.SavedBinary { var savedConfig adapter.SavedBinary err := c.DB.View(func(t *bbolt.Tx) error { @@ -398,3 +404,69 @@ func (c *CacheFile) SaveWARPConfig(tag string, set *adapter.SavedBinary) error { return bucket.Put([]byte(tag), configBinary) }) } + +func (c *CacheFile) LoadMASQUEConfig(tag string) *adapter.SavedBinary { + var savedConfig adapter.SavedBinary + err := c.DB.View(func(t *bbolt.Tx) error { + bucket := c.bucket(t, bucketRuleSet) + if bucket == nil { + return os.ErrNotExist + } + configBinary := bucket.Get([]byte(tag)) + if len(configBinary) == 0 { + return os.ErrInvalid + } + return savedConfig.UnmarshalBinary(configBinary) + }) + if err != nil { + return nil + } + return &savedConfig +} + +func (c *CacheFile) SaveMASQUEConfig(tag string, set *adapter.SavedBinary) error { + return c.DB.Batch(func(t *bbolt.Tx) error { + bucket, err := c.createBucket(t, bucketRuleSet) + if err != nil { + return err + } + configBinary, err := set.MarshalBinary() + if err != nil { + return err + } + return bucket.Put([]byte(tag), configBinary) + }) +} + +func (c *CacheFile) LoadSubscription(tag string) *adapter.SavedBinary { + var savedSet adapter.SavedBinary + err := c.DB.View(func(t *bbolt.Tx) error { + bucket := c.bucket(t, bucketRuleSet) + if bucket == nil { + return os.ErrNotExist + } + setBinary := bucket.Get([]byte(tag)) + if len(setBinary) == 0 { + return os.ErrInvalid + } + return savedSet.UnmarshalBinary(setBinary) + }) + if err != nil { + return nil + } + return &savedSet +} + +func (c *CacheFile) SaveSubscription(tag string, sub *adapter.SavedBinary) error { + return c.DB.Batch(func(t *bbolt.Tx) error { + bucket, err := c.createBucket(t, bucketRuleSet) + if err != nil { + return err + } + setBinary, err := sub.MarshalBinary() + if err != nil { + return err + } + return bucket.Put([]byte(tag), setBinary) + }) +} diff --git a/experimental/clashapi/provider.go b/experimental/clashapi/provider.go index 352b2894..f2487e49 100644 --- a/experimental/clashapi/provider.go +++ b/experimental/clashapi/provider.go @@ -4,48 +4,78 @@ import ( "context" "net/http" + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common/json/badjson" + "github.com/go-chi/chi/v5" "github.com/go-chi/render" ) -func proxyProviderRouter() http.Handler { +func proxyProviderRouter(server *Server) http.Handler { r := chi.NewRouter() - r.Get("/", getProviders) + r.Get("/", getProviders(server)) r.Route("/{name}", func(r chi.Router) { - r.Use(parseProviderName, findProviderByName) - r.Get("/", getProvider) + r.Use(parseProviderName, findProviderByName(server)) + r.Get("/", getProvider(server)) r.Put("/", updateProvider) r.Get("/healthcheck", healthCheckProvider) }) return r } -func getProviders(w http.ResponseWriter, r *http.Request) { - render.JSON(w, r, render.M{ - "providers": render.M{}, - }) +func getProviders(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + providerMap := make(render.M) + for _, provider := range server.provider.Providers() { + providerMap[provider.Tag()] = providerInfo(server, provider) + } + render.JSON(w, r, render.M{ + "providers": providerMap, + }) + } } -func getProvider(w http.ResponseWriter, r *http.Request) { - /*provider := r.Context().Value(CtxKeyProvider).(provider.ProxyProvider) - render.JSON(w, r, provider)*/ - render.NoContent(w, r) +func getProvider(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + provider := r.Context().Value(CtxKeyProvider).(adapter.Provider) + render.JSON(w, r, providerInfo(server, provider)) + } +} + +func providerInfo(server *Server, p adapter.Provider) *badjson.JSONObject { + var info badjson.JSONObject + proxies := make([]*badjson.JSONObject, 0) + for _, detour := range p.Outbounds() { + proxies = append(proxies, proxyInfo(server, detour)) + } + info.Put("type", "Proxy") // Proxy, Rule + info.Put("vehicleType", C.ProviderDisplayName(p.Type())) // HTTP, File, Compatible + info.Put("name", p.Tag()) + info.Put("proxies", proxies) + info.Put("updatedAt", p.UpdatedAt()) + if p, ok := p.(adapter.ProviderSubscriptionInfo); ok { + info.Put("subscriptionInfo", p.SubscriptionInfo()) + } + return &info } func updateProvider(w http.ResponseWriter, r *http.Request) { - /*provider := r.Context().Value(CtxKeyProvider).(provider.ProxyProvider) - if err := provider.Update(); err != nil { - render.Status(r, http.StatusServiceUnavailable) - render.JSON(w, r, newError(err.Error())) - return - }*/ + provider := r.Context().Value(CtxKeyProvider).(adapter.Provider) + if provider, isUpdater := provider.(adapter.ProviderUpdater); isUpdater { + if err := provider.Update(); err != nil { + render.Status(r, http.StatusServiceUnavailable) + render.JSON(w, r, newError(err.Error())) + return + } + } render.NoContent(w, r) } func healthCheckProvider(w http.ResponseWriter, r *http.Request) { - /*provider := r.Context().Value(CtxKeyProvider).(provider.ProxyProvider) - provider.HealthCheck()*/ + provider := r.Context().Value(CtxKeyProvider).(adapter.Provider) + provider.HealthCheck(r.Context()) render.NoContent(w, r) } @@ -57,18 +87,19 @@ func parseProviderName(next http.Handler) http.Handler { }) } -func findProviderByName(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - /*name := r.Context().Value(CtxKeyProviderName).(string) - providers := tunnel.ProxyProviders() - provider, exist := providers[name] - if !exist {*/ - render.Status(r, http.StatusNotFound) - render.JSON(w, r, ErrNotFound) - //return - //} +func findProviderByName(server *Server) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := r.Context().Value(CtxKeyProviderName).(string) + provider, exist := server.provider.Get(name) + if !exist { + render.Status(r, http.StatusNotFound) + render.JSON(w, r, ErrNotFound) + return + } - // ctx := context.WithValue(r.Context(), CtxKeyProvider, provider) - // next.ServeHTTP(w, r.WithContext(ctx)) - }) + ctx := context.WithValue(r.Context(), CtxKeyProvider, provider) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } } diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go index ec40a95f..c5255314 100644 --- a/experimental/clashapi/server.go +++ b/experimental/clashapi/server.go @@ -46,6 +46,7 @@ type Server struct { dnsRouter adapter.DNSRouter outbound adapter.OutboundManager endpoint adapter.EndpointManager + provider adapter.ProviderManager logger log.Logger httpServer *http.Server trafficManager *trafficontrol.Manager @@ -71,6 +72,7 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op dnsRouter: service.FromContext[adapter.DNSRouter](ctx), outbound: service.FromContext[adapter.OutboundManager](ctx), endpoint: service.FromContext[adapter.EndpointManager](ctx), + provider: service.FromContext[adapter.ProviderManager](ctx), logger: logFactory.NewLogger("clash-api"), httpServer: &http.Server{ Addr: options.ExternalController, @@ -122,7 +124,7 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op r.Mount("/proxies", proxyRouter(s, s.router)) r.Mount("/rules", ruleRouter(s.router)) r.Mount("/connections", connectionRouter(s.ctx, s.router, trafficManager)) - r.Mount("/providers/proxies", proxyProviderRouter()) + r.Mount("/providers/proxies", proxyProviderRouter(s)) r.Mount("/providers/rules", ruleProviderRouter()) r.Mount("/script", scriptRouter()) r.Mount("/profile", profileRouter()) diff --git a/experimental/libbox/config.go b/experimental/libbox/config.go index b1676ab6..45156f77 100644 --- a/experimental/libbox/config.go +++ b/experimental/libbox/config.go @@ -34,7 +34,7 @@ func baseContext(platformInterface PlatformInterface) context.Context { } ctx := context.Background() ctx = filemanager.WithDefault(ctx, sWorkingPath, sTempPath, sUserID, sGroupID) - return box.Context(ctx, include.InboundRegistry(), include.OutboundRegistry(), include.EndpointRegistry(), dnsRegistry, include.ServiceRegistry()) + return box.Context(ctx, include.InboundRegistry(), include.OutboundRegistry(), include.EndpointRegistry(), include.ProviderRegistry(), dnsRegistry, include.ServiceRegistry()) } func parseConfig(ctx context.Context, configContent string) (option.Options, error) { diff --git a/go.mod b/go.mod index 25600f60..a4cc47e5 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,11 @@ module github.com/sagernet/sing-box -go 1.25.5 +go 1.26.1 require ( + github.com/Diniboy1123/connect-ip-go v0.0.0-20260409225322-8d7bb0a858a2 + github.com/GoAdminGroup/go-admin v1.2.26 + github.com/GoAdminGroup/themes v0.0.48 github.com/anthropics/anthropic-sdk-go v1.26.0 github.com/anytls/sing-anytls v0.0.11 github.com/caddyserver/certmagic v0.25.2 @@ -10,12 +13,18 @@ require ( github.com/cretz/bine v0.2.0 github.com/database64128/tfo-go/v2 v2.3.2 github.com/enfein/mieru/v3 v3.17.1 + github.com/go-chi/chi v1.5.5 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/render v1.0.3 + github.com/go-playground/validator/v10 v10.30.1 github.com/godbus/dbus/v5 v5.2.2 github.com/gofrs/uuid/v5 v5.4.0 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/huandu/go-sqlbuilder v1.39.1 github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 + github.com/jackc/pgx/v5 v5.8.0 github.com/keybase/go-keychain v0.0.1 + github.com/lib/pq v1.10.9 github.com/libdns/acmedns v0.5.0 github.com/libdns/alidns v1.0.6 github.com/libdns/cloudflare v0.2.2 @@ -25,6 +34,7 @@ require ( github.com/miekg/dns v1.1.72 github.com/openai/openai-go/v3 v3.26.0 github.com/oschwald/maxminddb-golang v1.13.1 + github.com/patrickmn/go-cache/v2 v2.0.0-00010101000000-000000000000 github.com/sagernet/asc-go v0.0.0-20241217030726-d563060fe4e1 github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a github.com/sagernet/cors v1.2.1 @@ -46,25 +56,40 @@ require ( github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7 github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 + github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/vishvananda/netns v0.0.5 + github.com/yosida95/uritemplate/v3 v3.0.2 go.uber.org/zap v1.27.1 go4.org/netipx v0.0.0-20231129151722-fdeea329fbba - golang.org/x/crypto v0.48.0 - golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 - golang.org/x/mod v0.33.0 - golang.org/x/net v0.50.0 - golang.org/x/sys v0.41.0 + golang.org/x/crypto v0.49.0 + golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 + golang.org/x/mod v0.34.0 + golang.org/x/net v0.52.0 + golang.org/x/sys v0.42.0 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 google.golang.org/grpc v1.79.1 google.golang.org/protobuf v1.36.11 howett.net/plist v1.0.1 ) +require ( + github.com/OneOfOne/xxhash v1.2.8 // indirect + github.com/dunglas/httpsfv v1.1.0 // indirect + github.com/panjf2000/ants/v2 v2.12.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/tylertreat/BoomFilters v0.0.0-20251117164519-53813c36cc1b // indirect + github.com/yl2chen/cidranger v1.0.2 // indirect + gvisor.dev/gvisor v0.0.0-20260408064518-65a410b0d584 // indirect +) + require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/360EntSecGroup-Skylar/excelize v1.4.1 // indirect github.com/AdguardTeam/golibs v0.32.7 // 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 @@ -78,16 +103,24 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 // indirect + github.com/dolonet/mtg-multi v1.8.0 github.com/ebitengine/purego v0.9.1 // indirect github.com/florianl/go-nfqueue/v2 v2.0.2 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gaissmai/bart v0.18.0 // indirect + github.com/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-20250813024750-ebf49471dced // 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 v0.0.4 // 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 @@ -95,16 +128,29 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/huandu/go-clone v1.7.3 // indirect + github.com/huandu/xstrings v1.4.0 // 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/puddle/v2 v2.2.2 // indirect github.com/jsimonetti/rtnetlink v1.4.0 // indirect - github.com/klauspost/compress v1.18.0 // 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 + github.com/leodido/go-urn v1.4.0 // indirect github.com/libdns/libdns v1.1.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect github.com/mdlayher/netlink v1.9.0 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/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/nxadm/tail v1.4.11 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect - github.com/pires/go-proxyproto v0.8.1 // indirect + github.com/pires/go-proxyproto v0.11.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus-community/pro-bing v0.4.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect @@ -140,7 +186,8 @@ require ( github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260413092954-cd09eb3e271b // indirect github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect github.com/sagernet/nftables v0.3.0-mod.2 // indirect - github.com/spf13/pflag v1.0.9 // indirect + github.com/spf13/pflag v1.0.10 // 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/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect @@ -148,31 +195,49 @@ require ( github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect - github.com/tidwall/gjson v1.18.0 + github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/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/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/oauth2 v0.34.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/term v0.40.0 // indirect - golang.org/x/text v0.34.0 // indirect - golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.42.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.15.0 + golang.org/x/tools v0.43.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-20251202230838-ff82c1b0f217 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - lukechampine.com/blake3 v1.4.1 // 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 + lukechampine.com/blake3 v1.4.1 + 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.2-beta.1-extended-1.3.4 + +replace github.com/sagernet/wireguard-go => github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.4.0 replace github.com/sagernet/tailscale => github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.7-extended-1.0.2 +replace github.com/sagernet/sing-mux => github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0 + replace github.com/ameshkov/dnscrypt/v2 => github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0 + +replace github.com/sagernet/sing-vmess => github.com/starifly/sing-vmess v0.2.7-mod.9 + +replace github.com/patrickmn/go-cache/v2 => github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.0.2 + +replace github.com/dolonet/mtg-multi => github.com/shtorm-7/mtg-multi v1.8.0-extended-1.0.0 + +replace github.com/Diniboy1123/connect-ip-go => github.com/shtorm-7/connect-ip-go v1.0.0-extended-1.0.0 diff --git a/go.sum b/go.sum deleted file mode 100644 index 096719f6..00000000 --- a/go.sum +++ /dev/null @@ -1,413 +0,0 @@ -code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE= -code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM= -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/AdguardTeam/golibs v0.32.7 h1:3dmGlAVgmvquCCwHsvEl58KKcRAK3z1UnjMnwSIeDH4= -github.com/AdguardTeam/golibs v0.32.7/go.mod h1:bE8KV1zqTzgZjmjFyBJ9f9O5DEKO717r7e57j1HclJA= -github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= -github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= -github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= -github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= -github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= -github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= -github.com/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/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= -github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= -github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc= -github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8= -github.com/caddyserver/certmagic v0.25.2 h1:D7xcS7ggX/WEY54x0czj7ioTkmDWKIgxtIi2OcQclUc= -github.com/caddyserver/certmagic v0.25.2/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg= -github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= -github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= -github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= -github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= -github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= -github.com/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/cretz/bine v0.2.0 h1:8GiDRGlTgz+o8H9DSnsl+5MeBK4HsExxgl6WgzOCuZo= -github.com/cretz/bine v0.2.0/go.mod h1:WU4o9QR9wWp8AVKtTM1XD5vUHkEqnf2vVSo6dBqbetI= -github.com/database64128/netx-go v0.1.1 h1:dT5LG7Gs7zFZBthFBbzWE6K8wAHjSNAaK7wCYZT7NzM= -github.com/database64128/netx-go v0.1.1/go.mod h1:LNlYVipaYkQArRFDNNJ02VkNV+My9A5XR/IGS7sIBQc= -github.com/database64128/tfo-go/v2 v2.3.2 h1:UhZMKiMq3swZGUiETkLBDzQnZBPSAeBMClpJGlnJ5Fw= -github.com/database64128/tfo-go/v2 v2.3.2/go.mod h1:GC3uB5oa4beGpCUbRb2ZOWP73bJJFmMyAVgQSO7r724= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= -github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= -github.com/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/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= -github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= -github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= -github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= -github.com/enfein/mieru/v3 v3.17.1 h1:pIKbspsKRYNyUrORVI33t1/yz2syaaUkIanskAbGBHY= -github.com/enfein/mieru/v3 v3.17.1/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= -github.com/florianl/go-nfqueue/v2 v2.0.2 h1:FL5lQTeetgpCvac1TRwSfgaXUn0YSO7WzGvWNIp3JPE= -github.com/florianl/go-nfqueue/v2 v2.0.2/go.mod h1:VA09+iPOT43OMoCKNfXHyzujQUty2xmzyCRkBOlmabc= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= -github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= -github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo= -github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY= -github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= -github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= -github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= -github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= -github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= -github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= -github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= -github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= -github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I= -github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= -github.com/go-logr/logr v1.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/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= -github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= -github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= -github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= -github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= -github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= -github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= -github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -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.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/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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -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/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-20260220084031-5adc3eb26f91 h1:u9i04mGE3iliBh0EFuWaKsmcwrLacqGmq1G3XoaM7gY= -github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo= -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/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= -github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU= -github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk= -github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYqCLCnj/U= -github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ= -github.com/libdns/acmedns v0.5.0 h1:5pRtmUj4Lb/QkNJSl1xgOGBUJTWW7RjpNaIhjpDXjPE= -github.com/libdns/acmedns v0.5.0/go.mod h1:X7UAFP1Ep9NpTwWpVlrZzJLR7epynAy0wrIxSPFgKjQ= -github.com/libdns/alidns v1.0.6 h1:/Ii428ty6WHFJmE24rZxq2taq++gh7rf9jhgLfp8PmM= -github.com/libdns/alidns v1.0.6/go.mod h1:RECwyQ88e9VqQVtSrvX76o1ux3gQUKGzMgxICi+u7Ec= -github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI= -github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= -github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= -github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= -github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= -github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= -github.com/mdlayher/netlink v1.9.0 h1:G8+GLq2x3v4D4MVIqDdNUhTUC7TKiCy/6MDkmItfKco= -github.com/mdlayher/netlink v1.9.0/go.mod h1:YBnl5BXsCoRuwBjKKlZ+aYmEoq0r12FDA/3JC+94KDg= -github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= -github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= -github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg= -github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= -github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= -github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY= -github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= -github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= -github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= -github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= -github.com/openai/openai-go/v3 v3.26.0 h1:bRt6H/ozMNt/dDkN4gobnLqaEGrRGBzmbVs0xxJEnQE= -github.com/openai/openai-go/v3 v3.26.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= -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/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= -github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= -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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= -github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -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= -github.com/sagernet/asc-go v0.0.0-20241217030726-d563060fe4e1 h1:qi+ijeREa0yfAaO+NOcZ81gv4uzOfALUIdhkiIFvmG4= -github.com/sagernet/asc-go v0.0.0-20241217030726-d563060fe4e1/go.mod h1:JULDuzTMn2gyZFcjpTVZP4/UuwAdbHJ0bum2RdjXojU= -github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a h1:+NkI2670SQpQWvkkD2QgdTuzQG263YZ+2emfpeyGqW0= -github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a/go.mod h1:63s7jpZqcDAIpj8oI/1v4Izok+npJOHACFCU6+huCkM= -github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ= -github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI= -github.com/sagernet/cronet-go v0.0.0-20260413093659-e4926ba205fa h1:7SehNSF1UHbLZa5dk+1rW1aperffJzl5r6TCJIXtAaY= -github.com/sagernet/cronet-go v0.0.0-20260413093659-e4926ba205fa/go.mod h1:hwFHBEjjthyEquDULbr4c4ucMedp8Drb6Jvm2kt/0Bw= -github.com/sagernet/cronet-go/all v0.0.0-20260413093659-e4926ba205fa h1:ijk5v9N/akiMgqu734yMpv7Pk9F4Qmjh8Vfdcb4uJHE= -github.com/sagernet/cronet-go/all v0.0.0-20260413093659-e4926ba205fa/go.mod h1:+FENo4+0AOvH9e3oY6/iO7yy7USNt61dgbnI5W0TDZ0= -github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260413092954-cd09eb3e271b h1:O+PkYT88ayVWESX5tqxeMeS9OnzC3ZTic8gYiPJNXT8= -github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= -github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260413092954-cd09eb3e271b h1:o0MsgbsJwYkbqlbfaCvmAwb8/LAXeoSP8NE/aNvR/yY= -github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= -github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260413092954-cd09eb3e271b h1:JEQnc7cRMUahWJFtWY6n0hs1LE0KgyRv3pD0RWS8Yo8= -github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= -github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:69+AKzuUW9hzw2nU79c2DWfuzrIZ3PJm1KAwXh+7xr0= -github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= -github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260413092954-cd09eb3e271b h1:jp9FHUVTCJQ67Ecw3Inoct6/z1VTFXPtNYpXt47pa4E= -github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= -github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:WN3DZoECd2UbhmYQGpOA4jx4QBXiZuN1DvL/35NT61g= -github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= -github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260413092954-cd09eb3e271b h1:H4RKicwrIa4PwTXZOmXOg85hiCrpeFja4daOlX180pE= -github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= -github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:Rwi+Cu+Hgwj28F1lh837gGqSqn7oU8+r5i3UJyLPkKc= -github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= -github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260413092954-cd09eb3e271b h1:v2wcnPX3gt0PngFYXjXYAiarFckwx3pVAP6ETSpbSWE= -github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= -github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260413092954-cd09eb3e271b h1:Bl0zZ3QZq6pPJMbQlYHDhhaGngVefRlFzxWc0p48eHo= -github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= -github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260413092954-cd09eb3e271b h1:vf+MbGv6RvvmXUNvganykBOnDIVXxy8XgtKOOqOcxtE= -github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= -github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260413092954-cd09eb3e271b h1:2IAc1bVFYF+B6hof34ChQKVhw7LElBxEEx7S0n+7o78= -github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= -github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260413092954-cd09eb3e271b h1:NrJaiOS0VLmWTbUHhXDsLTqelmCW4y3xJqptPs4Sx0s= -github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= -github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260413092954-cd09eb3e271b h1:A+ubSkca1nl2cT8pYUqCo1O7M41suNrKpWhZKCM/aIQ= -github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= -github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:WrhGH5FDXlCAoXwN6N44yCMvy6EbIurmTmptkz3mmms= -github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= -github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260413092954-cd09eb3e271b h1:kgwB5p5e0gdVX5iYRE7VbZS/On4qnb4UKonkGPwhkDI= -github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= -github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260413092954-cd09eb3e271b h1:Z3dOeFlRIOeQhSh+mCYDHui1yR3S/Uw8eupczzBvxqw= -github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= -github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260413092954-cd09eb3e271b h1:LPi6jz1k11Q67hm3Pw6aaPJ/Z6e3VtNhzrRjr5/5AQo= -github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= -github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260413092954-cd09eb3e271b h1:55sqihyfXWN7y7p7gOEgtUz9cm1mV3SDQ90/v6ROFaA= -github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= -github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260413092954-cd09eb3e271b h1:OTA1cbv5YIDVsYA8AAXHC4NgEc7b6pDiY+edujLWfJU= -github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= -github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260413092954-cd09eb3e271b h1:B/rdD/1A+RgqUYUZcoGhLeMqijnBd1mUt8+5LhOH7j8= -github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= -github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260413092954-cd09eb3e271b h1:QFRWi6FucrODS4xQ8e9GYIzGSeMFO/DAMtTCVeJiCvM= -github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= -github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260413092954-cd09eb3e271b h1:2WJjPKZHLNIB4D17c3o9S+SP9kb3Qh0D26oWlun1+pE= -github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= -github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260413092954-cd09eb3e271b h1:cUNTe4gNncRpYL28jzQf6qcJej40zzGQsH0o6CLUGws= -github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= -github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260413092954-cd09eb3e271b h1:+sc1LJF0FjU2hVO5xBqqT+8qzoU08J2uHwxSle2m/Hw= -github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= -github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:+D/uhFxllI/KTLpeNEl8dwF3omPGmUFbrqt5tJkAyp0= -github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= -github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260413092954-cd09eb3e271b h1:nSUzzTUAZdqjGGckayk64sz+F0TGJPHvauTiAn27UKk= -github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= -github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260413092954-cd09eb3e271b h1:PE/fYBiHzB52gnQMg0soBfQyJCzmWHti48kCe2TBt9w= -github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= -github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:hy/3lPV11pKAAojDFnb95l9NpwOym6kME7FxS9p8sXs= -github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= -github.com/sagernet/fswatch v0.1.2 h1:/TT7k4mkce1qFPxamLO842WjqBgbTBiXP2mlUjp9PFk= -github.com/sagernet/fswatch v0.1.2/go.mod h1:5BpGmpUQVd3Mc5r313HRpvADHRg3/rKn5QbwFteB880= -github.com/sagernet/gomobile v0.1.12 h1:XwzjZaclFF96deLqwAgK8gU3w0M2A8qxgDmhV+A0wjg= -github.com/sagernet/gomobile v0.1.12/go.mod h1:A8l3FlHi2D/+mfcd4HHvk5DGFPW/ShFb9jHP5VmSiDY= -github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 h1:AzCE2RhBjLJ4WIWc/GejpNh+z30d5H1hwaB0nD9eY3o= -github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1/go.mod h1:NJKBtm9nVEK3iyOYWsUlrDQuoGh4zJ4KOPhSYVidvQ4= -github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis= -github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= -github.com/sagernet/nftables v0.3.0-mod.2 h1:ck2KMU02OxL1eDFgGaWYglMDpoOZ7OHzxje+vW5Q0OQ= -github.com/sagernet/nftables v0.3.0-mod.2/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= -github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o= -github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= -github.com/sagernet/sing v0.8.9 h1:iX8FyMrWNl/divVgTe7cLT9n36v6bfzfnCYlcM1cLaU= -github.com/sagernet/sing v0.8.9/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= -github.com/sagernet/sing-mux v0.3.4 h1:ZQplKl8MNXutjzbMVtWvWG31fohhgOfCuUZR4dVQ8+s= -github.com/sagernet/sing-mux v0.3.4/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= -github.com/sagernet/sing-quic v0.6.1 h1:lx0tcm99wIA1RkyvILNzRSsMy1k7TTQYIhx71E/WBlw= -github.com/sagernet/sing-quic v0.6.1/go.mod h1:K5bWvITOm4vE10fwLfrWpw27bCoVJ+tfQ79tOWg+Ko8= -github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= -github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI= -github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo= -github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ= -github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 h1:tK+75l64tm9WvEFrYRE1t0YxoFdWQqw/h7Uhzj0vJ+w= -github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= -github.com/sagernet/sing-tun v0.8.9 h1:ixFKKUGdVcJl4wb0xbL36hobiw9l6DIH497EQf5ILpM= -github.com/sagernet/sing-tun v0.8.9/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ= -github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= -github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY= -github.com/sagernet/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/tailscale v1.92.4-sing-box-1.13-mod.7-extended-1.0.2 h1:hSMjh97OszszOd8HrzpaYUQH9dWRRBluJCbwQyz8ZOk= -github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.7-extended-1.0.2/go.mod h1:TYIIqO5sZpWq873rLIeO2usszSMUpR3h6WdqVVs65ug= -github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.3.4 h1:t/2ZxRo8cwvydImFaKuUSDrcZYhX753JiXGe7411krI= -github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.3.4/go.mod h1:Me2JlCDYHxnd0mnuX7L5LXAeDHCltI7vSKq3eTE6SVE= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -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= -github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= -github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio= -github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= -github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= -github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= -github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= -github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= -github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= -github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= -github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= -github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= -github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= -github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= -github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= -github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= -github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= -github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= -github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= -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/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= -github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= -github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= -github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= -go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= -go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= -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-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= -golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= -golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= -golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= -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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= -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.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= -golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= -golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= -golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= -golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= -golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= -google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gvisor.dev/gvisor v0.0.0-20260309223511-c9735035b4c6 h1:q2oSL6CrUQDUOT8D70ImK10gGRTIZjhR7fgSU//5kc0= -gvisor.dev/gvisor v0.0.0-20260309223511-c9735035b4c6/go.mod h1:xQ2PWgHmWJA/Ph4i1q1jBm39BKhc3W0DXqWoDSyuBOY= -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= diff --git a/include/masque.go b/include/masque.go new file mode 100644 index 00000000..fdd75d98 --- /dev/null +++ b/include/masque.go @@ -0,0 +1,12 @@ +//go:build with_masque + +package include + +import ( + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/protocol/masque" +) + +func registerMASQUEOutbound(registry *outbound.Registry) { + masque.RegisterOutbound(registry) +} diff --git a/include/masque_stub.go b/include/masque_stub.go new file mode 100644 index 00000000..fc31da68 --- /dev/null +++ b/include/masque_stub.go @@ -0,0 +1,20 @@ +//go:build !with_masque + +package include + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func registerMASQUEOutbound(registry *outbound.Registry) { + outbound.Register[option.MASQUEOutboundOptions](registry, C.TypeMASQUE, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.MASQUEOutboundOptions) (adapter.Outbound, error) { + return nil, E.New(`MASQUE outbound is not included in this build, rebuild with -tags with_masque`) + }) +} diff --git a/include/mtproxy.go b/include/mtproxy.go new file mode 100644 index 00000000..2fba9693 --- /dev/null +++ b/include/mtproxy.go @@ -0,0 +1,12 @@ +//go:build with_mtproxy + +package include + +import ( + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/protocol/mtproxy" +) + +func registerMTProxyInbound(registry *inbound.Registry) { + mtproxy.RegisterInbound(registry) +} diff --git a/include/mtproxy_stub.go b/include/mtproxy_stub.go new file mode 100644 index 00000000..c06a98fc --- /dev/null +++ b/include/mtproxy_stub.go @@ -0,0 +1,20 @@ +//go:build !with_mtproxy + +package include + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + 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 registerMTProxyInbound(registry *inbound.Registry) { + inbound.Register[option.MTProxyInboundOptions](registry, C.TypeMTProxy, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.MTProxyInboundOptions) (adapter.Inbound, error) { + return nil, E.New(`MTProxy is not included in this build, rebuild with -tags with_mtproxy`) + }) +} diff --git a/include/registry.go b/include/registry.go index d63e870f..a34d8075 100644 --- a/include/registry.go +++ b/include/registry.go @@ -8,6 +8,7 @@ import ( "github.com/sagernet/sing-box/adapter/endpoint" "github.com/sagernet/sing-box/adapter/inbound" "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/adapter/provider" "github.com/sagernet/sing-box/adapter/service" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/dns" @@ -19,12 +20,16 @@ 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" "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" + "github.com/sagernet/sing-box/protocol/parser" "github.com/sagernet/sing-box/protocol/redirect" "github.com/sagernet/sing-box/protocol/shadowsocks" "github.com/sagernet/sing-box/protocol/shadowtls" @@ -33,16 +38,23 @@ import ( "github.com/sagernet/sing-box/protocol/tor" "github.com/sagernet/sing-box/protocol/trojan" "github.com/sagernet/sing-box/protocol/tun" - "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/protocol/vpn" + localProvider "github.com/sagernet/sing-box/provider/local" + remoteProvider "github.com/sagernet/sing-box/provider/remote" + "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" ) func Context(ctx context.Context) context.Context { - return box.Context(ctx, InboundRegistry(), OutboundRegistry(), EndpointRegistry(), DNSTransportRegistry(), ServiceRegistry()) + return box.Context(ctx, InboundRegistry(), OutboundRegistry(), EndpointRegistry(), ProviderRegistry(), DNSTransportRegistry(), ServiceRegistry()) } func InboundRegistry() *inbound.Registry { @@ -65,8 +77,11 @@ func InboundRegistry() *inbound.Registry { vless.RegisterInbound(registry) anytls.RegisterInbound(registry) + bond.RegisterInbound(registry) + registerQUICInbounds(registry) registerStubForRemovedInbounds(registry) + registerMTProxyInbound(registry) return registry } @@ -78,6 +93,7 @@ func OutboundRegistry() *outbound.Registry { block.RegisterOutbound(registry) + group.RegisterFallback(registry) group.RegisterSelector(registry) group.RegisterURLTest(registry) @@ -93,6 +109,14 @@ func OutboundRegistry() *outbound.Registry { vless.RegisterOutbound(registry) mieru.RegisterOutbound(registry) anytls.RegisterOutbound(registry) + registerMASQUEOutbound(registry) + + bond.RegisterOutbound(registry) + + bandwidth.RegisterOutbound(registry) + connection.RegisterOutbound(registry) + + parser.RegisterOutbound(registry) registerQUICOutbounds(registry) registerStubForRemovedOutbounds(registry) @@ -103,8 +127,8 @@ func OutboundRegistry() *outbound.Registry { func EndpointRegistry() *endpoint.Registry { registry := endpoint.NewRegistry() - tunnel.RegisterServerEndpoint(registry) - tunnel.RegisterClientEndpoint(registry) + vpn.RegisterServerEndpoint(registry) + vpn.RegisterClientEndpoint(registry) registerWireGuardEndpoint(registry) registerTailscaleEndpoint(registry) @@ -112,6 +136,16 @@ func EndpointRegistry() *endpoint.Registry { return registry } +func ProviderRegistry() *provider.Registry { + registry := provider.NewRegistry() + + localProvider.RegisterProviderInline(registry) + localProvider.RegisterProviderLocal(registry) + remoteProvider.RegisterProvider(registry) + + return registry +} + func DNSTransportRegistry() *dns.TransportRegistry { registry := dns.NewTransportRegistry() @@ -135,6 +169,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/include/wireguard.go b/include/wireguard.go index 40f881d1..43b10200 100644 --- a/include/wireguard.go +++ b/include/wireguard.go @@ -4,10 +4,11 @@ package include import ( "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/protocol/warp" "github.com/sagernet/sing-box/protocol/wireguard" ) func registerWireGuardEndpoint(registry *endpoint.Registry) { wireguard.RegisterEndpoint(registry) - wireguard.RegisterWARPEndpoint(registry) + warp.RegisterEndpoint(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/cloudflare.go b/option/cloudflare.go new file mode 100644 index 00000000..6fbd0805 --- /dev/null +++ b/option/cloudflare.go @@ -0,0 +1,9 @@ +package option + +type CloudflareProfile struct { + ID string `json:"id,omitempty"` + AuthToken string `json:"auth_token,omitempty"` + PrivateKey string `json:"private_key,omitempty"` + Recreate bool `json:"recreate,omitempty"` + Detour string `json:"detour,omitempty"` +} diff --git a/option/experimental.go b/option/experimental.go index 0487881b..031a8cbc 100644 --- a/option/experimental.go +++ b/option/experimental.go @@ -11,13 +11,14 @@ type ExperimentalOptions struct { } type CacheFileOptions struct { - Enabled bool `json:"enabled,omitempty"` - Path string `json:"path,omitempty"` - CacheID string `json:"cache_id,omitempty"` - StoreFakeIP bool `json:"store_fakeip,omitempty"` - StoreRDRC bool `json:"store_rdrc,omitempty"` - StoreWARPConfig bool `json:"store_warp_config,omitempty"` - RDRCTimeout badoption.Duration `json:"rdrc_timeout,omitempty"` + Enabled bool `json:"enabled,omitempty"` + Path string `json:"path,omitempty"` + CacheID string `json:"cache_id,omitempty"` + StoreFakeIP bool `json:"store_fakeip,omitempty"` + StoreRDRC bool `json:"store_rdrc,omitempty"` + StoreWARPConfig bool `json:"store_warp_config,omitempty"` + StoreMASQUEConfig bool `json:"store_masque_config,omitempty"` + RDRCTimeout badoption.Duration `json:"rdrc_timeout,omitempty"` } type ClashAPIOptions struct { diff --git a/option/failover.go b/option/failover.go new file mode 100644 index 00000000..73bdeb34 --- /dev/null +++ b/option/failover.go @@ -0,0 +1,9 @@ +package option + +type FailoverInboundOptions struct { + Inbounds []Inbound `json:"inbounds"` +} + +type FailoverOutboundOptions struct { + Outbounds []Outbound `json:"outbounds"` +} diff --git a/option/group.go b/option/group.go index 02b3a5ec..2fb8e65b 100644 --- a/option/group.go +++ b/option/group.go @@ -3,16 +3,28 @@ package option import "github.com/sagernet/sing/common/json/badoption" type SelectorOutboundOptions struct { - Outbounds []string `json:"outbounds"` - Default string `json:"default,omitempty"` - InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` + GroupCommonOption + Default string `json:"default,omitempty"` + InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` } type URLTestOutboundOptions struct { - Outbounds []string `json:"outbounds"` + GroupCommonOption URL string `json:"url,omitempty"` Interval badoption.Duration `json:"interval,omitempty"` Tolerance uint16 `json:"tolerance,omitempty"` IdleTimeout badoption.Duration `json:"idle_timeout,omitempty"` InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` } + +type FallbackOutboundOptions struct { + Outbounds []string `json:"outbounds"` +} + +type GroupCommonOption struct { + Outbounds []string `json:"outbounds"` + Providers []string `json:"providers"` + Exclude *badoption.Regexp `json:"exclude,omitempty"` + Include *badoption.Regexp `json:"include,omitempty"` + UseAllProviders bool `json:"use_all_providers,omitempty"` +} 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/masque.go b/option/masque.go new file mode 100644 index 00000000..83fa849a --- /dev/null +++ b/option/masque.go @@ -0,0 +1,32 @@ +package option + +import ( + "github.com/sagernet/sing/common/json/badoption" +) + +type MASQUEOutboundOptions struct { + UseHTTP2 bool `json:"use_http2,omitempty"` + UseIPv6 bool `json:"use_ipv6,omitempty"` + Profile CloudflareProfile `json:"profile,omitempty"` + UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` + UDPKeepalivePeriod badoption.Duration `json:"udp_keepalive_period,omitempty"` + UDPInitialPacketSize uint16 `json:"udp_initial_packet_size,omitempty"` + ReconnectDelay badoption.Duration `json:"reconnect_delay,omitempty"` + MASQUEOutboundTLSOptions + DialerOptions +} + +type MASQUEOutboundTLSOptions struct { + Insecure bool `json:"insecure,omitempty"` + CipherSuites badoption.Listable[string] `json:"cipher_suites,omitempty"` + CurvePreferences badoption.Listable[CurvePreference] `json:"curve_preferences,omitempty"` + Fragment bool `json:"fragment,omitempty"` + FragmentFallbackDelay badoption.Duration `json:"fragment_fallback_delay,omitempty"` + RecordFragment bool `json:"record_fragment,omitempty"` + KernelTx bool `json:"kernel_tx,omitempty"` + KernelRx bool `json:"kernel_rx,omitempty"` +} + +type MASQUEOutboundTLSOptionsContainer struct { + TLS *OutboundTLSOptions `json:"tls,omitempty"` +} diff --git a/option/mtproxy.go b/option/mtproxy.go new file mode 100644 index 00000000..4e903a4a --- /dev/null +++ b/option/mtproxy.go @@ -0,0 +1,89 @@ +package option + +import ( + "time" + + "github.com/sagernet/sing/common/json/badoption" +) + +type MTProxyInboundOptions struct { + ListenOptions + Users []MTProxyUser `json:"users,omitempty"` + Concurrency uint `json:"concurrency,omitempty"` + DomainFrontingPort uint `json:"domain_fronting_port,omitempty"` + DomainFrontingIP string `json:"domain_fronting_ip,omitempty"` + DomainFrontingProxyProtocol bool `json:"domain_fronting_proxy_protocol,omitempty"` + PreferIP string `json:"prefer_ip,omitempty"` + AutoUpdate bool `json:"auto_update,omitempty"` + AllowFallbackOnUnknownDC bool `json:"allow_fallback_on_unknown_dc,omitempty"` + TolerateTimeSkewness badoption.Duration `json:"tolerate_time_skewness,omitempty"` + IdleTimeout badoption.Duration `json:"idle_timeout,omitempty"` + HandshakeTimeout badoption.Duration `json:"handshake_timeout,omitempty"` + DoppelGangerURLs []string `json:"doppelganger_urls,omitempty"` + DoppelGangerPerRaid uint `json:"doppelganger_per_raid,omitempty"` + DoppelGangerEach badoption.Duration `json:"doppelganger_each,omitempty"` + DoppelGangerDRS bool `json:"doppelganger_drs,omitempty"` + ThrottleMaxConnections uint `json:"throttle_max_connections,omitempty"` + ThrottleCheckInterval badoption.Duration `json:"throttle_check_interval,omitempty"` +} + +func (o *MTProxyInboundOptions) GetConcurrency() uint { + if o.Concurrency == 0 { + return 8192 + } + return o.Concurrency +} + +func (o *MTProxyInboundOptions) GetDomainFrontingPort() uint { + if o.DomainFrontingPort == 0 { + return 443 + } + return o.DomainFrontingPort +} + +func (o *MTProxyInboundOptions) GetPreferIP() string { + if o.PreferIP == "" { + return "prefer-ipv4" + } + return o.PreferIP +} + +func (o *MTProxyInboundOptions) GetIdleTimeout() time.Duration { + if o.IdleTimeout == 0 { + return 5 * time.Minute + } + return o.IdleTimeout.Build() +} + +func (o *MTProxyInboundOptions) GetHandshakeTimeout() time.Duration { + if o.HandshakeTimeout == 0 { + return 10 * time.Second + } + return o.HandshakeTimeout.Build() +} + +func (o *MTProxyInboundOptions) GetDoppelGangerPerRaid() uint { + if o.DoppelGangerPerRaid == 0 { + return 10 + } + return o.DoppelGangerPerRaid +} + +func (o *MTProxyInboundOptions) GetDoppelGangerEach() time.Duration { + if o.HandshakeTimeout == 0 { + return 6 * time.Hour + } + return o.DoppelGangerEach.Build() +} + +func (o *MTProxyInboundOptions) GetThrottleCheckInterval() time.Duration { + if o.ThrottleCheckInterval == 0 { + return 5 * time.Second + } + return o.ThrottleCheckInterval.Build() +} + +type MTProxyUser struct { + Name string `json:"name"` + Secret string `json:"secret"` +} 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/options.go b/option/options.go index 8bebd48f..fcca94c3 100644 --- a/option/options.go +++ b/option/options.go @@ -19,6 +19,7 @@ type _Options struct { Endpoints []Endpoint `json:"endpoints,omitempty"` Inbounds []Inbound `json:"inbounds,omitempty"` Outbounds []Outbound `json:"outbounds,omitempty"` + Providers []Provider `json:"providers,omitempty"` Route *RouteOptions `json:"route,omitempty"` Services []Service `json:"services,omitempty"` Experimental *ExperimentalOptions `json:"experimental,omitempty"` diff --git a/option/parser.go b/option/parser.go new file mode 100644 index 00000000..db916c8d --- /dev/null +++ b/option/parser.go @@ -0,0 +1,6 @@ +package option + +type ParserOutboundOptions struct { + DialerOptions + Link string `json:"link"` +} diff --git a/option/provider.go b/option/provider.go new file mode 100644 index 00000000..656036e4 --- /dev/null +++ b/option/provider.go @@ -0,0 +1,75 @@ +package option + +import ( + "context" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/common/json/badoption" + "github.com/sagernet/sing/service" +) + +type ProviderOptionsRegistry interface { + CreateOptions(providerType string) (any, bool) +} +type _Provider struct { + Type string `json:"type"` + Tag string `json:"tag,omitempty"` + Options any `json:"-"` +} + +type Provider _Provider + +func (h *Provider) MarshalJSONContext(ctx context.Context) ([]byte, error) { + return badjson.MarshallObjectsContext(ctx, (*_Provider)(h), h.Options) +} + +func (h *Provider) UnmarshalJSONContext(ctx context.Context, content []byte) error { + err := json.UnmarshalContext(ctx, content, (*_Provider)(h)) + if err != nil { + return err + } + registry := service.FromContext[ProviderOptionsRegistry](ctx) + if registry == nil { + return E.New("missing provider options registry in context") + } + options, loaded := registry.CreateOptions(h.Type) + if !loaded { + return E.New("unknown provider type: ", h.Type) + } + err = badjson.UnmarshallExcludedContext(ctx, content, (*_Provider)(h), options) + if err != nil { + return err + } + h.Options = options + return nil +} + +type ProviderLocalOptions struct { + Path string `json:"path"` + HealthCheck ProviderHealthCheckOptions `json:"health_check,omitempty"` +} + +type ProviderRemoteOptions struct { + URL string `json:"url"` + UserAgent string `json:"user_agent,omitempty"` + DownloadDetour string `json:"download_detour,omitempty"` + UpdateInterval badoption.Duration `json:"update_interval,omitempty"` + + Exclude *badoption.Regexp `json:"exclude,omitempty"` + Include *badoption.Regexp `json:"include,omitempty"` + HealthCheck ProviderHealthCheckOptions `json:"health_check,omitempty"` +} + +type ProviderInlineOptions struct { + Outbounds []Outbound `json:"outbounds,omitempty"` + HealthCheck ProviderHealthCheckOptions `json:"health_check,omitempty"` +} + +type ProviderHealthCheckOptions struct { + Enabled bool `json:"enabled,omitempty"` + URL string `json:"url,omitempty"` + Interval badoption.Duration `json:"interval,omitempty"` + Timeout badoption.Duration `json:"timeout,omitempty"` +} diff --git a/option/rule.go b/option/rule.go index ba732616..3e7fd877 100644 --- a/option/rule.go +++ b/option/rule.go @@ -88,8 +88,6 @@ type RawDefaultRule struct { SourcePortRange badoption.Listable[string] `json:"source_port_range,omitempty"` Port badoption.Listable[uint16] `json:"port,omitempty"` PortRange badoption.Listable[string] `json:"port_range,omitempty"` - TunnelSource badoption.Listable[string] `json:"tunnel_source,omitempty"` - TunnelDestination badoption.Listable[string] `json:"tunnel_destination,omitempty"` ProcessName badoption.Listable[string] `json:"process_name,omitempty"` ProcessPath badoption.Listable[string] `json:"process_path,omitempty"` ProcessPathRegex badoption.Listable[string] `json:"process_path_regex,omitempty"` diff --git a/option/rule_action.go b/option/rule_action.go index bfe12625..8ecb0dda 100644 --- a/option/rule_action.go +++ b/option/rule_action.go @@ -155,9 +155,10 @@ type RouteActionOptions struct { } type RawRouteOptionsActionOptions struct { - OverrideAddress string `json:"override_address,omitempty"` - OverridePort uint16 `json:"override_port,omitempty"` - OverrideTunnelDestination string `json:"override_tunnel_destination,omitempty"` + OverrideAddress string `json:"override_address,omitempty"` + OverridePort uint16 `json:"override_port,omitempty"` + + OverrideGateway string `json:"override_gateway,omitempty"` NetworkStrategy *NetworkStrategy `json:"network_strategy,omitempty"` FallbackDelay uint32 `json:"fallback_delay,omitempty"` diff --git a/option/rule_dns.go b/option/rule_dns.go index d34cba23..dbc16578 100644 --- a/option/rule_dns.go +++ b/option/rule_dns.go @@ -90,8 +90,6 @@ type RawDefaultDNSRule struct { SourcePortRange badoption.Listable[string] `json:"source_port_range,omitempty"` Port badoption.Listable[uint16] `json:"port,omitempty"` PortRange badoption.Listable[string] `json:"port_range,omitempty"` - TunnelSource badoption.Listable[string] `json:"tunnel_source,omitempty"` - TunnelDestination badoption.Listable[string] `json:"tunnel_destination,omitempty"` ProcessName badoption.Listable[string] `json:"process_name,omitempty"` ProcessPath badoption.Listable[string] `json:"process_path,omitempty"` ProcessPathRegex badoption.Listable[string] `json:"process_path_regex,omitempty"` diff --git a/option/rule_set.go b/option/rule_set.go index 8155055f..b0634228 100644 --- a/option/rule_set.go +++ b/option/rule_set.go @@ -194,8 +194,6 @@ type DefaultHeadlessRule struct { SourcePortRange badoption.Listable[string] `json:"source_port_range,omitempty"` Port badoption.Listable[uint16] `json:"port,omitempty"` PortRange badoption.Listable[string] `json:"port_range,omitempty"` - TunnelSource badoption.Listable[string] `json:"tunnel_source,omitempty"` - TunnelDestination badoption.Listable[string] `json:"tunnel_destination,omitempty"` ProcessName badoption.Listable[string] `json:"process_name,omitempty"` ProcessPath badoption.Listable[string] `json:"process_path,omitempty"` ProcessPathRegex badoption.Listable[string] `json:"process_path_regex,omitempty"` diff --git a/option/tunnel.go b/option/tunnel.go deleted file mode 100644 index cc1df36b..00000000 --- a/option/tunnel.go +++ /dev/null @@ -1,21 +0,0 @@ -package option - -import "github.com/sagernet/sing/common/json/badoption" - -type TunnelClientEndpointOptions struct { - UUID string `json:"uuid"` - Key string `json:"key"` - Outbound Outbound `json:"outbound"` -} - -type TunnelServerEndpointOptions struct { - UUID string `json:"uuid"` - Users []TunnelUser `json:"users"` - Inbound Inbound `json:"inbound"` - ConnectTimeout badoption.Duration `json:"connect_timeout,omitempty"` -} - -type TunnelUser struct { - UUID string `json:"uuid"` - Key string `json:"key"` -} diff --git a/option/v2ray_transport.go b/option/v2ray_transport.go index f54d2529..2737d747 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) } @@ -468,3 +473,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/vless.go b/option/vless.go index 5acf2aee..989bcc65 100644 --- a/option/vless.go +++ b/option/vless.go @@ -2,7 +2,8 @@ package option type VLESSInboundOptions struct { ListenOptions - Users []VLESSUser `json:"users,omitempty"` + Users []VLESSUser `json:"users,omitempty"` + Decryption string `json:"decryption,omitempty"` InboundTLSOptionsContainer Multiplex *InboundMultiplexOptions `json:"multiplex,omitempty"` Transport *V2RayTransportOptions `json:"transport,omitempty"` @@ -17,9 +18,10 @@ type VLESSUser struct { type VLESSOutboundOptions struct { DialerOptions ServerOptions - UUID string `json:"uuid"` - Flow string `json:"flow,omitempty"` - Network NetworkList `json:"network,omitempty"` + UUID string `json:"uuid"` + Flow string `json:"flow,omitempty"` + Encryption string `json:"encryption,omitempty"` + Network NetworkList `json:"network,omitempty"` OutboundTLSOptionsContainer Multiplex *OutboundMultiplexOptions `json:"multiplex,omitempty"` Transport *V2RayTransportOptions `json:"transport,omitempty"` diff --git a/option/vpn.go b/option/vpn.go new file mode 100644 index 00000000..49139b9f --- /dev/null +++ b/option/vpn.go @@ -0,0 +1,25 @@ +package option + +import ( + "net/netip" + + "github.com/sagernet/sing/common/json/badoption" +) + +type VPNClientEndpointOptions struct { + Address netip.Addr `json:"address"` + Key string `json:"key"` + Outbound Outbound `json:"outbound"` +} + +type VPNServerEndpointOptions struct { + Address netip.Addr `json:"address"` + Users []VPNUser `json:"users"` + Inbounds []Inbound `json:"inbounds"` + ConnectTimeout badoption.Duration `json:"connect_timeout,omitempty"` +} + +type VPNUser struct { + Address netip.Addr `json:"address"` + Key string `json:"key"` +} diff --git a/option/warp.go b/option/warp.go new file mode 100644 index 00000000..f1ede310 --- /dev/null +++ b/option/warp.go @@ -0,0 +1,18 @@ +package option + +import "github.com/sagernet/sing/common/json/badoption" + +type WARPEndpointOptions struct { + 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 CloudflareProfile `json:"profile,omitempty"` + DialerOptions +} diff --git a/option/wireguard.go b/option/wireguard.go index 2d3d1ffd..04f8b039 100644 --- a/option/wireguard.go +++ b/option/wireguard.go @@ -3,6 +3,7 @@ package option import ( "net/netip" + Xbadoption "github.com/sagernet/sing-box/common/xray/json/badoption" "github.com/sagernet/sing/common/json/badoption" ) @@ -32,46 +33,25 @@ type WireGuardPeer struct { Reserved []uint8 `json:"reserved,omitempty"` } -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"` - DialerOptions -} - -type WARPProfile struct { - ID string `json:"id,omitempty"` - PrivateKey string `json:"private_key,omitempty"` - AuthToken string `json:"auth_token,omitempty"` - Recreate bool `json:"recreate,omitempty"` - Detour string `json:"detour,omitempty"` -} - type WireGuardAmnezia struct { - JC int `json:"jc,omitempty"` - JMin int `json:"jmin,omitempty"` - JMax int `json:"jmax,omitempty"` - S1 int `json:"s1,omitempty"` - S2 int `json:"s2,omitempty"` - S3 int `json:"s3,omitempty"` - S4 int `json:"s4,omitempty"` - H1 uint32 `json:"h1,omitempty"` - H2 uint32 `json:"h2,omitempty"` - H3 uint32 `json:"h3,omitempty"` - H4 uint32 `json:"h4,omitempty"` - I1 string `json:"i1,omitempty"` - I2 string `json:"i2,omitempty"` - I3 string `json:"i3,omitempty"` - I4 string `json:"i4,omitempty"` - I5 string `json:"i5,omitempty"` - J1 string `json:"j1,omitempty"` - J2 string `json:"j2,omitempty"` - J3 string `json:"j3,omitempty"` - ITime int64 `json:"itime,omitempty"` + JC int `json:"jc,omitempty"` + JMin int `json:"jmin,omitempty"` + JMax int `json:"jmax,omitempty"` + S1 int `json:"s1,omitempty"` + S2 int `json:"s2,omitempty"` + S3 int `json:"s3,omitempty"` + S4 int `json:"s4,omitempty"` + H1 *Xbadoption.Range `json:"h1,omitempty"` + H2 *Xbadoption.Range `json:"h2,omitempty"` + H3 *Xbadoption.Range `json:"h3,omitempty"` + H4 *Xbadoption.Range `json:"h4,omitempty"` + I1 string `json:"i1,omitempty"` + I2 string `json:"i2,omitempty"` + I3 string `json:"i3,omitempty"` + I4 string `json:"i4,omitempty"` + I5 string `json:"i5,omitempty"` + J1 string `json:"j1,omitempty"` + J2 string `json:"j2,omitempty"` + J3 string `json:"j3,omitempty"` + ITime int64 `json:"itime,omitempty"` } diff --git a/parser/clash/anytls.go b/parser/clash/anytls.go new file mode 100644 index 00000000..5f6356bf --- /dev/null +++ b/parser/clash/anytls.go @@ -0,0 +1,30 @@ +package clash + +import ( + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/json/badoption" +) + +type AnyTLSOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + TLSOptions `yaml:",inline"` + Password string `yaml:"password"` + UDP bool `yaml:"udp,omitempty"` + IdleSessionCheckInterval int `yaml:"idle-session-check-interval,omitempty"` + IdleSessionTimeout int `yaml:"idle-session-timeout,omitempty"` + MinIdleSession int `yaml:"min-idle-session,omitempty"` +} + +func (a *AnyTLSOption) Build() any { + a.TLS = true + return &option.AnyTLSOutboundOptions{ + DialerOptions: a.DialerOptions.Build(), + ServerOptions: a.ServerOptions.Build(), + OutboundTLSOptionsContainer: clashTLSOptions(a.Server, &a.TLSOptions), + Password: a.Password, + IdleSessionCheckInterval: badoption.Duration(a.IdleSessionCheckInterval), + IdleSessionTimeout: badoption.Duration(a.IdleSessionTimeout), + MinIdleSession: a.MinIdleSession, + } +} diff --git a/parser/clash/base.go b/parser/clash/base.go new file mode 100644 index 00000000..cc275c3b --- /dev/null +++ b/parser/clash/base.go @@ -0,0 +1,181 @@ +package clash + +import ( + "encoding/base64" + "strings" + + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/json/badoption" +) + +type HTTPOptions struct { + Method string `yaml:"method,omitempty"` + Path []string `yaml:"path,omitempty"` + Headers badoption.HTTPHeader `yaml:"headers,omitempty"` +} + +type HTTP2Options struct { + Host []string `yaml:"host,omitempty"` + Path string `yaml:"path,omitempty"` +} + +type GrpcOptions struct { + GrpcServiceName string `yaml:"grpc-service-name,omitempty"` +} + +type WSOptions struct { + Path string `yaml:"path,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` + MaxEarlyData int `yaml:"max-early-data,omitempty"` + EarlyDataHeaderName string `yaml:"early-data-header-name,omitempty"` + V2rayHttpUpgrade bool `yaml:"v2ray-http-upgrade,omitempty"` +} + +type MuxOptions struct { + Enabled bool `yaml:"enabled,omitempty"` + Protocol string `yaml:"protocol,omitempty"` + MaxConnections int `yaml:"max-connections,omitempty"` + MinStreams int `yaml:"min-streams,omitempty"` + MaxStreams int `yaml:"max-streams,omitempty"` + Padding bool `yaml:"padding,omitempty"` + BrutalOpts *BrutalOptions `yaml:"brutal-opts,omitempty"` +} + +func (s *MuxOptions) Build() *option.OutboundMultiplexOptions { + if s == nil { + return nil + } + return &option.OutboundMultiplexOptions{ + Enabled: s.Enabled, + Protocol: s.Protocol, + MaxConnections: s.MaxConnections, + MinStreams: s.MinStreams, + MaxStreams: s.MaxStreams, + Padding: s.Padding, + Brutal: s.BrutalOpts.Build(), + } +} + +type BrutalOptions struct { + Enabled bool `yaml:"enabled,omitempty"` + Up string `yaml:"up,omitempty"` + Down string `yaml:"down,omitempty"` +} + +func (b *BrutalOptions) Build() *option.BrutalOptions { + if b == nil { + return nil + } + return &option.BrutalOptions{ + Enabled: b.Enabled, + UpMbps: clashSpeedToIntMbps(b.Up), + DownMbps: clashSpeedToIntMbps(b.Down), + } +} + +type RealityOptions struct { + PublicKey string `yaml:"public-key"` + ShortID string `yaml:"short-id"` +} + +func (r *RealityOptions) Build() *option.OutboundRealityOptions { + if r == nil { + return nil + } + return &option.OutboundRealityOptions{ + Enabled: true, + PublicKey: r.PublicKey, + ShortID: r.ShortID, + } +} + +type ECHOptions struct { + Enable bool `yaml:"enable,omitempty"` + Config string `yaml:"config,omitempty"` +} + +func (e *ECHOptions) Build() *option.OutboundECHOptions { + if e == nil { + return nil + } + list, err := base64.StdEncoding.DecodeString(e.Config) + if err != nil { + return nil + } + return &option.OutboundECHOptions{ + Enabled: e.Enable, + Config: trimStringArray(strings.Split(string(list), "\n")), + } +} + +type TLSOptions struct { + TLS bool `yaml:"tls,omitempty"` + SNI string `yaml:"sni,omitempty"` + SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` + ALPN []string `yaml:"alpn,omitempty"` + ClientFingerprint string `yaml:"client-fingerprint,omitempty"` + CustomCA string `yaml:"ca,omitempty"` + CustomCAString string `yaml:"ca-str,omitempty"` + Certificate string `yaml:"certificate,omitempty"` + PrivateKey string `yaml:"private-key,omitempty"` + ECHOpts *ECHOptions `yaml:"ech-opts,omitempty"` + RealityOpts *RealityOptions `yaml:"reality-opts,omitempty"` +} + +func (t *TLSOptions) Build() *option.OutboundTLSOptions { + if t == nil { + return nil + } + options := &option.OutboundTLSOptions{ + Enabled: t.TLS, + ServerName: t.SNI, + Insecure: t.SkipCertVerify, + ALPN: t.ALPN, + UTLS: clashClientFingerprint(t.ClientFingerprint), + Certificate: trimStringArray(strings.Split(t.CustomCAString, "\n")), + CertificatePath: t.CustomCA, + ECH: t.ECHOpts.Build(), + Reality: t.RealityOpts.Build(), + } + if strings.HasPrefix(t.Certificate, "-----BEGIN ") { + options.ClientCertificate = trimStringArray(strings.Split(t.Certificate, "\n")) + } else { + options.ClientCertificatePath = t.Certificate + } + if strings.HasPrefix(t.PrivateKey, "-----BEGIN ") { + options.ClientKey = trimStringArray(strings.Split(t.PrivateKey, "\n")) + } else { + options.ClientKeyPath = t.PrivateKey + } + return options +} + +type DialerOptions struct { + TFO bool `yaml:"tfo,omitempty"` + MPTCP bool `yaml:"mptcp,omitempty"` + Interface string `yaml:"interface-name,omitempty"` + RoutingMark int `yaml:"routing-mark,omitempty"` + DialerProxy string `yaml:"dialer-proxy,omitempty"` +} + +func (b *DialerOptions) Build() option.DialerOptions { + return option.DialerOptions{ + Detour: b.DialerProxy, + BindInterface: b.Interface, + TCPFastOpen: b.TFO, + TCPMultiPath: b.MPTCP, + RoutingMark: option.FwMark(b.RoutingMark), + } +} + +type ServerOptions struct { + Server string `yaml:"server"` + Port int `yaml:"port"` +} + +func (s *ServerOptions) Build() option.ServerOptions { + return option.ServerOptions{ + Server: s.Server, + ServerPort: uint16(s.Port), + } +} diff --git a/parser/clash/http.go b/parser/clash/http.go new file mode 100644 index 00000000..f176c3ed --- /dev/null +++ b/parser/clash/http.go @@ -0,0 +1,23 @@ +package clash + +import "github.com/sagernet/sing-box/option" + +type HttpOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + *TLSOptions `yaml:",inline"` + UserName string `yaml:"username,omitempty"` + Password string `yaml:"password,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` +} + +func (h *HttpOption) Build() any { + return &option.HTTPOutboundOptions{ + DialerOptions: h.DialerOptions.Build(), + ServerOptions: h.ServerOptions.Build(), + Username: h.UserName, + Password: h.Password, + OutboundTLSOptionsContainer: clashTLSOptions(h.Server, h.TLSOptions), + Headers: clashHeaders(h.Headers), + } +} diff --git a/parser/clash/hysteria.go b/parser/clash/hysteria.go new file mode 100644 index 00000000..9d35ccdd --- /dev/null +++ b/parser/clash/hysteria.go @@ -0,0 +1,47 @@ +package clash + +import ( + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/json/badoption" +) + +type HysteriaOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + TLSOptions `yaml:",inline"` + Ports string `yaml:"ports,omitempty"` + Up string `yaml:"up"` + UpSpeed int `yaml:"up-speed,omitempty"` // compatible with Stash + Down string `yaml:"down"` + DownSpeed int `yaml:"down-speed,omitempty"` // compatible with Stash + Auth string `yaml:"auth,omitempty"` + AuthString string `yaml:"auth-str,omitempty"` + Obfs string `yaml:"obfs,omitempty"` + ReceiveWindowConn int `yaml:"recv-window-conn,omitempty"` + ReceiveWindow int `yaml:"recv-window,omitempty"` + DisableMTUDiscovery bool `yaml:"disable-mtu-discovery,omitempty"` + FastOpen bool `yaml:"fast-open,omitempty"` + HopInterval int `yaml:"hop-interval,omitempty"` +} + +func (h *HysteriaOption) Build() any { + h.TLS = true + h.TFO = h.FastOpen + return &option.HysteriaOutboundOptions{ + DialerOptions: h.DialerOptions.Build(), + ServerOptions: h.ServerOptions.Build(), + ServerPorts: clashPorts(h.Ports), + HopInterval: badoption.Duration(h.HopInterval), + Up: clashSpeedToNetworkBytes(h.Up), + UpMbps: h.UpSpeed, + Down: clashSpeedToNetworkBytes(h.Down), + DownMbps: h.DownSpeed, + Obfs: h.Obfs, + Auth: []byte(h.Auth), + AuthString: h.AuthString, + ReceiveWindowConn: uint64(h.ReceiveWindowConn), + ReceiveWindow: uint64(h.ReceiveWindow), + DisableMTUDiscovery: h.DisableMTUDiscovery, + OutboundTLSOptionsContainer: clashTLSOptions(h.Server, &h.TLSOptions), + } +} diff --git a/parser/clash/hysteria2.go b/parser/clash/hysteria2.go new file mode 100644 index 00000000..e2f37ba2 --- /dev/null +++ b/parser/clash/hysteria2.go @@ -0,0 +1,34 @@ +package clash + +import ( + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/json/badoption" +) + +type Hysteria2Option struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + TLSOptions `yaml:",inline"` + Ports string `yaml:"ports,omitempty"` + HopInterval int `yaml:"hop-interval,omitempty"` + Up string `yaml:"up,omitempty"` + Down string `yaml:"down,omitempty"` + Password string `yaml:"password,omitempty"` + Obfs string `yaml:"obfs,omitempty"` + ObfsPassword string `yaml:"obfs-password,omitempty"` +} + +func (h *Hysteria2Option) Build() any { + h.TLS = true + return &option.Hysteria2OutboundOptions{ + DialerOptions: h.DialerOptions.Build(), + ServerOptions: h.ServerOptions.Build(), + ServerPorts: clashPorts(h.Ports), + HopInterval: badoption.Duration(h.HopInterval), + UpMbps: clashSpeedToIntMbps(h.Up), + DownMbps: clashSpeedToIntMbps(h.Down), + Obfs: clashHysteria2Obfs(h.Obfs, h.ObfsPassword), + Password: h.Password, + OutboundTLSOptionsContainer: clashTLSOptions(h.Server, &h.TLSOptions), + } +} diff --git a/parser/clash/parser.go b/parser/clash/parser.go new file mode 100644 index 00000000..61efa865 --- /dev/null +++ b/parser/clash/parser.go @@ -0,0 +1,106 @@ +package clash + +import ( + "context" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + + "gopkg.in/yaml.v3" +) + +type ClashConfig struct { + Proxies []ClashProxy `yaml:"proxies"` +} + +type _ClashProxy struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Options Proxy `yaml:"-"` + + SingType string `yaml:"-"` +} +type ClashProxy _ClashProxy + +type Proxy interface { + Build() any +} + +func (c *ClashProxy) UnmarshalYAML(value *yaml.Node) error { + err := value.Decode((*_ClashProxy)(c)) + if err != nil { + return err + } + var options Proxy + switch c.Type { + case "ss": + c.SingType = C.TypeShadowsocks + options = &ShadowSocksOption{} + case "tuic": + c.SingType = C.TypeTUIC + options = &TuicOption{} + case "vmess": + c.SingType = C.TypeVMess + options = &VmessOption{} + case "vless": + c.SingType = C.TypeVLESS + options = &VlessOption{} + case "socks5": + c.SingType = C.TypeSOCKS + options = &Socks5Option{} + case "http": + c.SingType = C.TypeHTTP + options = &HttpOption{} + case "trojan": + c.SingType = C.TypeTrojan + options = &TrojanOption{} + case "hysteria": + c.SingType = C.TypeHysteria + options = &HysteriaOption{} + case "hysteria2": + c.SingType = C.TypeHysteria2 + options = &Hysteria2Option{} + case "ssh": + c.SingType = C.TypeSSH + options = &SSHOption{} + case "anytls": + c.SingType = C.TypeAnyTLS + options = &AnyTLSOption{} + default: + return nil + } + err = value.Decode(options) + if err != nil { + return err + } + c.Options = options + return nil +} + +func (c *ClashProxy) Build() option.Outbound { + outbound := option.Outbound{ + Tag: c.Name, + Type: c.SingType, + } + if c.Options != nil { + outbound.Options = c.Options.Build() + } + return outbound +} + +func ParseClashSubscription(_ context.Context, content string) ([]option.Outbound, error) { + config := &ClashConfig{} + err := yaml.Unmarshal([]byte(content), &config) + if err != nil { + return nil, E.Cause(err, "parse clash config") + } + outbounds := common.FilterIsInstance(config.Proxies, func(proxy ClashProxy) (option.Outbound, bool) { + if proxy.SingType == "" { + return option.Outbound{}, false + } + return proxy.Build(), true + }) + return outbounds, nil +} diff --git a/parser/clash/shadowsocks.go b/parser/clash/shadowsocks.go new file mode 100644 index 00000000..f45d59cf --- /dev/null +++ b/parser/clash/shadowsocks.go @@ -0,0 +1,51 @@ +package clash + +import ( + "strings" + + "github.com/sagernet/sing-box/option" + F "github.com/sagernet/sing/common/format" +) + +type ShadowSocksOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + Password string `yaml:"password"` + Cipher string `yaml:"cipher"` + UDP bool `yaml:"udp,omitempty"` + Plugin string `yaml:"plugin,omitempty"` + PluginOpts map[string]any `yaml:"plugin-opts,omitempty"` + UDPOverTCP bool `yaml:"udp-over-tcp,omitempty"` + UDPOverTCPVersion int `yaml:"udp-over-tcp-version,omitempty"` + MuxOpts *MuxOptions `yaml:"smux,omitempty"` +} + +func (s *ShadowSocksOption) Build() any { + return &option.ShadowsocksOutboundOptions{ + DialerOptions: s.DialerOptions.Build(), + ServerOptions: s.ServerOptions.Build(), + Password: s.Password, + Method: clashShadowsocksCipher(s.Cipher), + Plugin: clashPluginName(s.Plugin), + PluginOptions: clashPluginOptions(s.Plugin, s.PluginOpts), + Network: clashNetworks(s.UDP), + UDPOverTCP: &option.UDPOverTCPOptions{ + Enabled: s.UDPOverTCP, + Version: uint8(s.UDPOverTCPVersion), + }, + Multiplex: s.MuxOpts.Build(), + } +} + +type shadowsocksPluginOptionsBuilder map[string]any + +func (o shadowsocksPluginOptionsBuilder) Build() string { + var opts []string + for key, value := range o { + if value == nil { + continue + } + opts = append(opts, F.ToString(key, "=", value)) + } + return strings.Join(opts, ";") +} diff --git a/parser/clash/socks5.go b/parser/clash/socks5.go new file mode 100644 index 00000000..7c1dd390 --- /dev/null +++ b/parser/clash/socks5.go @@ -0,0 +1,21 @@ +package clash + +import "github.com/sagernet/sing-box/option" + +type Socks5Option struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + UserName string `yaml:"username,omitempty"` + Password string `yaml:"password,omitempty"` + UDP bool `yaml:"udp,omitempty"` +} + +func (s *Socks5Option) Build() any { + return &option.SOCKSOutboundOptions{ + DialerOptions: s.DialerOptions.Build(), + ServerOptions: s.ServerOptions.Build(), + Username: s.UserName, + Password: s.Password, + Network: clashNetworks(s.UDP), + } +} diff --git a/parser/clash/ssh.go b/parser/clash/ssh.go new file mode 100644 index 00000000..7010634b --- /dev/null +++ b/parser/clash/ssh.go @@ -0,0 +1,36 @@ +package clash + +import ( + "strings" + + "github.com/sagernet/sing-box/option" +) + +type SSHOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + UserName string `yaml:"username"` + Password string `yaml:"password,omitempty"` + PrivateKey string `yaml:"private-key,omitempty"` + PrivateKeyPassphrase string `yaml:"private-key-passphrase,omitempty"` + HostKey []string `yaml:"host-key,omitempty"` + HostKeyAlgorithms []string `yaml:"host-key-algorithms,omitempty"` +} + +func (s *SSHOption) Build() any { + options := &option.SSHOutboundOptions{ + DialerOptions: s.DialerOptions.Build(), + ServerOptions: s.ServerOptions.Build(), + User: s.UserName, + Password: s.Password, + PrivateKeyPassphrase: s.PrivateKeyPassphrase, + HostKey: s.HostKey, + HostKeyAlgorithms: s.HostKeyAlgorithms, + } + if strings.Contains(s.PrivateKey, "PRIVATE KEY") { + options.PrivateKey = trimStringArray(strings.Split(s.PrivateKey, "\n")) + } else { + options.PrivateKeyPath = s.PrivateKey + } + return options +} diff --git a/parser/clash/trojan.go b/parser/clash/trojan.go new file mode 100644 index 00000000..5d391c7a --- /dev/null +++ b/parser/clash/trojan.go @@ -0,0 +1,28 @@ +package clash + +import "github.com/sagernet/sing-box/option" + +type TrojanOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + TLSOptions `yaml:",inline"` + Password string `yaml:"password"` + UDP bool `yaml:"udp,omitempty"` + Network string `yaml:"network,omitempty"` + GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"` + WSOpts WSOptions `yaml:"ws-opts,omitempty"` + MuxOpts *MuxOptions `yaml:"smux,omitempty"` +} + +func (t *TrojanOption) Build() any { + t.TLS = true + return &option.TrojanOutboundOptions{ + DialerOptions: t.DialerOptions.Build(), + ServerOptions: t.ServerOptions.Build(), + Password: t.Password, + Network: clashNetworks(t.UDP), + OutboundTLSOptionsContainer: clashTLSOptions(t.Server, &t.TLSOptions), + Multiplex: t.MuxOpts.Build(), + Transport: clashTransport(t.Network, HTTPOptions{}, HTTP2Options{}, t.GrpcOpts, t.WSOpts), + } +} diff --git a/parser/clash/tuic.go b/parser/clash/tuic.go new file mode 100644 index 00000000..fb2ebccf --- /dev/null +++ b/parser/clash/tuic.go @@ -0,0 +1,47 @@ +package clash + +import ( + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/json/badoption" +) + +type TuicOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + TLSOptions `yaml:",inline"` + UUID string `yaml:"uuid,omitempty"` + Password string `yaml:"password,omitempty"` + Ip string `yaml:"ip,omitempty"` + HeartbeatInterval int `yaml:"heartbeat-interval,omitempty"` + DisableSni bool `yaml:"disable-sni,omitempty"` + ReduceRtt bool `yaml:"reduce-rtt,omitempty"` + UdpRelayMode string `yaml:"udp-relay-mode,omitempty"` + CongestionController string `yaml:"congestion-controller,omitempty"` + FastOpen bool `yaml:"fast-open,omitempty"` + DisableMTUDiscovery bool `yaml:"disable-mtu-discovery,omitempty"` + UDPOverStream bool `yaml:"udp-over-stream,omitempty"` +} + +func (t *TuicOption) Build() any { + t.TLS = true + t.TFO = t.FastOpen + options := &option.TUICOutboundOptions{ + DialerOptions: t.DialerOptions.Build(), + ServerOptions: t.ServerOptions.Build(), + UUID: t.UUID, + Password: t.Password, + CongestionControl: t.CongestionController, + UDPRelayMode: t.UdpRelayMode, + UDPOverStream: t.UDPOverStream, + ZeroRTTHandshake: t.ReduceRtt, + Heartbeat: badoption.Duration(t.HeartbeatInterval), + OutboundTLSOptionsContainer: clashTLSOptions(t.Server, &t.TLSOptions), + } + if t.Ip != "" { + options.Server = t.Ip + } + if t.DisableSni { + options.TLS.DisableSNI = true + } + return options +} diff --git a/parser/clash/utils.go b/parser/clash/utils.go new file mode 100644 index 00000000..9055532c --- /dev/null +++ b/parser/clash/utils.go @@ -0,0 +1,205 @@ +package clash + +import ( + "strconv" + "strings" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/byteformats" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json/badoption" + N "github.com/sagernet/sing/common/network" +) + +func clashClientFingerprint(clientFingerprint string) *option.OutboundUTLSOptions { + if clientFingerprint == "" { + return nil + } + return &option.OutboundUTLSOptions{ + Enabled: true, + Fingerprint: clientFingerprint, + } +} + +func clashHeaders(headers map[string]string) map[string]badoption.Listable[string] { + if headers == nil { + return nil + } + result := make(map[string]badoption.Listable[string]) + for key, value := range headers { + result[key] = []string{value} + } + return result +} + +func clashHysteria2Obfs(obfs string, password string) *option.Hysteria2Obfs { + if obfs == "" { + return nil + } + return &option.Hysteria2Obfs{ + Type: obfs, + Password: password, + } +} + +func clashNetworks(udpEnabled bool) option.NetworkList { + if !udpEnabled { + return N.NetworkTCP + } + return "" +} + +func clashPluginName(plugin string) string { + switch plugin { + case "obfs": + return "obfs-local" + } + return plugin +} + +func clashPluginOptions(plugin string, opts map[string]any) string { + options := make(shadowsocksPluginOptionsBuilder) + switch plugin { + case "obfs": + options["obfs"] = opts["mode"] + options["obfs-host"] = opts["host"] + case "v2ray-plugin": + options["mode"] = opts["mode"] + options["tls"] = opts["tls"] + options["host"] = opts["host"] + options["path"] = opts["path"] + } + return options.Build() +} + +func clashPorts(ports string) badoption.Listable[string] { + if ports == "" { + return nil + } + serverPorts := badoption.Listable[string]{} + ports = strings.ReplaceAll(ports, "/", ",") + for _, port := range strings.Split(ports, ",") { + if port == "" { + continue + } + port = strings.Replace(port, "-", ":", 1) + serverPorts = append(serverPorts, port) + } + return serverPorts +} + +func clashShadowsocksCipher(cipher string) string { + switch cipher { + case "dummy": + return "none" + } + return cipher +} + +func clashStringList(list []string) string { + if len(list) > 0 { + return list[0] + } + return "" +} + +func clashSpeedToIntMbps(speed string) int { + if speed == "" { + return 0 + } + if num, err := strconv.Atoi(speed); err == nil { + return num + } + networkBytes := byteformats.NetworkBytesCompat{} + if err := networkBytes.UnmarshalJSON([]byte(speed)); err != nil { + return 0 + } + return int(networkBytes.Value() / byteformats.MByte * 8) +} + +func clashSpeedToNetworkBytes(speed string) *byteformats.NetworkBytesCompat { + if speed == "" { + return nil + } + networkBytes := &byteformats.NetworkBytesCompat{} + if num, err := strconv.Atoi(speed); err == nil { + speed = F.ToString(num, "Mbps") + } + if err := networkBytes.UnmarshalJSON([]byte(speed)); err != nil { + return nil + } + return networkBytes +} + +func clashTransport(network string, httpOpts HTTPOptions, h2Opts HTTP2Options, grpcOpts GrpcOptions, wsOpts WSOptions) *option.V2RayTransportOptions { + switch network { + case "http": + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeHTTP, + HTTPOptions: option.V2RayHTTPOptions{ + Method: httpOpts.Method, + Path: clashStringList(httpOpts.Path), + Headers: httpOpts.Headers, + }, + } + case "h2": + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeHTTP, + HTTPOptions: option.V2RayHTTPOptions{ + Path: h2Opts.Path, + Host: h2Opts.Host, + }, + } + case "grpc": + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeGRPC, + GRPCOptions: option.V2RayGRPCOptions{ + ServiceName: grpcOpts.GrpcServiceName, + }, + } + case "ws": + headers := clashHeaders(wsOpts.Headers) + if wsOpts.V2rayHttpUpgrade { + var host string + if headers != nil && headers["Host"] != nil { + host = headers["Host"][0] + } + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeHTTPUpgrade, + HTTPUpgradeOptions: option.V2RayHTTPUpgradeOptions{ + Host: host, + Path: wsOpts.Path, + Headers: headers, + }, + } + } + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeWebsocket, + WebsocketOptions: option.V2RayWebsocketOptions{ + Path: wsOpts.Path, + Headers: headers, + MaxEarlyData: uint32(wsOpts.MaxEarlyData), + EarlyDataHeaderName: wsOpts.EarlyDataHeaderName, + }, + } + default: + return nil + } +} + +func clashTLSOptions(server string, tlsOptions *TLSOptions) option.OutboundTLSOptionsContainer { + if tlsOptions != nil && tlsOptions.SNI == "" { + tlsOptions.SNI = server + } + return option.OutboundTLSOptionsContainer{ + TLS: tlsOptions.Build(), + } +} + +func trimStringArray(array []string) []string { + return common.Filter(array, func(it string) bool { + return strings.TrimSpace(it) != "" + }) +} diff --git a/parser/clash/vless.go b/parser/clash/vless.go new file mode 100644 index 00000000..d5494562 --- /dev/null +++ b/parser/clash/vless.go @@ -0,0 +1,49 @@ +package clash + +import "github.com/sagernet/sing-box/option" + +type VlessOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + *TLSOptions `yaml:",inline"` + UUID string `yaml:"uuid"` + Flow string `yaml:"flow,omitempty"` + UDP bool `yaml:"udp,omitempty"` + PacketAddr bool `yaml:"packet-addr,omitempty"` + XUDP bool `yaml:"xudp,omitempty"` + PacketEncoding string `yaml:"packet-encoding,omitempty"` + Network string `yaml:"network,omitempty"` + ServerName string `yaml:"servername,omitempty"` + HTTPOpts HTTPOptions `yaml:"http-opts,omitempty"` + HTTP2Opts HTTP2Options `yaml:"h2-opts,omitempty"` + GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"` + WSOpts WSOptions `yaml:"ws-opts,omitempty"` + MuxOpts *MuxOptions `yaml:"smux,omitempty"` +} + +func (v *VlessOption) Build() any { + if v.TLSOptions != nil { + v.SNI = v.ServerName + } + switch v.PacketEncoding { + case "": + if v.PacketAddr { + v.PacketEncoding = "packetaddr" + } else { + v.PacketEncoding = "xudp" + } + case "packet": + v.PacketEncoding = "packetaddr" + } + return &option.VLESSOutboundOptions{ + DialerOptions: v.DialerOptions.Build(), + ServerOptions: v.ServerOptions.Build(), + UUID: v.UUID, + Flow: v.Flow, + Network: clashNetworks(v.UDP), + OutboundTLSOptionsContainer: clashTLSOptions(v.Server, v.TLSOptions), + Multiplex: v.MuxOpts.Build(), + Transport: clashTransport(v.Network, v.HTTPOpts, v.HTTP2Opts, v.GrpcOpts, v.WSOpts), + PacketEncoding: &v.PacketEncoding, + } +} diff --git a/parser/clash/vmess.go b/parser/clash/vmess.go new file mode 100644 index 00000000..fccea09a --- /dev/null +++ b/parser/clash/vmess.go @@ -0,0 +1,55 @@ +package clash + +import "github.com/sagernet/sing-box/option" + +type VmessOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + *TLSOptions `yaml:",inline"` + UUID string `yaml:"uuid"` + AlterID int `yaml:"alterId"` + Cipher string `yaml:"cipher"` + UDP bool `yaml:"udp,omitempty"` + Network string `yaml:"network,omitempty"` + ServerName string `yaml:"servername,omitempty"` + HTTPOpts HTTPOptions `yaml:"http-opts,omitempty"` + HTTP2Opts HTTP2Options `yaml:"h2-opts,omitempty"` + GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"` + WSOpts WSOptions `yaml:"ws-opts,omitempty"` + PacketAddr bool `yaml:"packet-addr,omitempty"` + XUDP bool `yaml:"xudp,omitempty"` + PacketEncoding string `yaml:"packet-encoding,omitempty"` + GlobalPadding bool `yaml:"global-padding,omitempty"` + AuthenticatedLength bool `yaml:"authenticated-length,omitempty"` + MuxOpts *MuxOptions `yaml:"smux,omitempty"` +} + +func (v *VmessOption) Build() any { + if v.TLSOptions != nil { + v.SNI = v.ServerName + } + switch v.PacketEncoding { + case "": + if v.XUDP { + v.PacketEncoding = "xudp" + } else if v.PacketAddr { + v.PacketEncoding = "packetaddr" + } + case "packet": + v.PacketEncoding = "packetaddr" + } + return &option.VMessOutboundOptions{ + DialerOptions: v.DialerOptions.Build(), + ServerOptions: v.ServerOptions.Build(), + UUID: v.UUID, + Security: v.Cipher, + AlterId: v.AlterID, + GlobalPadding: v.GlobalPadding, + AuthenticatedLength: v.AuthenticatedLength, + Network: clashNetworks(v.UDP), + OutboundTLSOptionsContainer: clashTLSOptions(v.Server, v.TLSOptions), + PacketEncoding: v.PacketEncoding, + Multiplex: v.MuxOpts.Build(), + Transport: clashTransport(v.Network, v.HTTPOpts, v.HTTP2Opts, v.GrpcOpts, v.WSOpts), + } +} diff --git a/parser/link/hysteria.go b/parser/link/hysteria.go new file mode 100644 index 00000000..4e756af1 --- /dev/null +++ b/parser/link/hysteria.go @@ -0,0 +1,71 @@ +package link + +import ( + "net/url" + "strconv" + "strings" + + "github.com/sagernet/sing-box/common" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/byteformats" +) + +func parseHysteriaLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + var options option.HysteriaOutboundOptions + TLSOptions := option.OutboundTLSOptions{ + Enabled: true, + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + options.Server = linkURL.Hostname() + TLSOptions.ServerName = linkURL.Hostname() + options.ServerPort = common.StringToType[uint16](linkURL.Port()) + for key, values := range linkURL.Query() { + value := values[0] + switch key { + case "auth": + options.AuthString = value + case "peer", "sni": + TLSOptions.ServerName = value + case "alpn": + TLSOptions.ALPN = strings.Split(value, ",") + case "ca": + TLSOptions.CertificatePath = value + case "ca_str": + TLSOptions.Certificate = strings.Split(value, "\n") + case "up": + options.Up = &byteformats.NetworkBytesCompat{} + options.Up.UnmarshalJSON([]byte(value)) + case "up_mbps": + options.UpMbps, _ = strconv.Atoi(value) + case "down": + options.Down = &byteformats.NetworkBytesCompat{} + options.Down.UnmarshalJSON([]byte(value)) + case "down_mbps": + options.DownMbps, _ = strconv.Atoi(value) + case "obfs", "obfsParam": + options.Obfs = value + case "insecure", "skip-cert-verify": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + case "tfo", "tcp-fast-open", "tcp_fast_open": + if value == "1" || value == "true" { + options.TCPFastOpen = true + } + } + } + outbound := option.Outbound{ + Type: C.TypeHysteria, + Tag: linkURL.Fragment, + } + options.TLS = &TLSOptions + outbound.Options = &options + return outbound, nil +} diff --git a/parser/link/hysteria2.go b/parser/link/hysteria2.go new file mode 100644 index 00000000..d9486064 --- /dev/null +++ b/parser/link/hysteria2.go @@ -0,0 +1,61 @@ +package link + +import ( + "net/url" + "strconv" + + "github.com/sagernet/sing-box/common" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" +) + +func parseHysteria2Link(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + var options option.Hysteria2OutboundOptions + TLSOptions := option.OutboundTLSOptions{ + Enabled: true, + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + Obfs := &option.Hysteria2Obfs{} + options.ServerPort = uint16(443) + options.Server = linkURL.Hostname() + TLSOptions.ServerName = linkURL.Hostname() + if linkURL.User != nil { + options.Password = linkURL.User.Username() + } + if linkURL.Port() != "" { + options.ServerPort = common.StringToType[uint16](linkURL.Port()) + } + for key, values := range linkURL.Query() { + value := values[0] + switch key { + case "up": + options.UpMbps, _ = strconv.Atoi(value) + case "down": + options.DownMbps, _ = strconv.Atoi(value) + case "obfs": + if value == "salamander" { + Obfs.Type = "salamander" + options.Obfs = Obfs + } + case "obfs-password": + Obfs.Password = value + case "insecure", "skip-cert-verify": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + } + } + outbound := option.Outbound{ + Type: C.TypeHysteria2, + Tag: linkURL.Fragment, + } + options.TLS = &TLSOptions + outbound.Options = &options + return outbound, nil +} diff --git a/parser/link/parser.go b/parser/link/parser.go new file mode 100644 index 00000000..72e35ab6 --- /dev/null +++ b/parser/link/parser.go @@ -0,0 +1,42 @@ +package link + +import ( + "regexp" + "strings" + + "github.com/sagernet/sing-box/common" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func ParseSubscriptionLink(link string) (option.Outbound, error) { + reg := regexp.MustCompile(`^(.*?)(://)(.*?)([@?#].*)?$`) + result := reg.FindStringSubmatch(link) + if result == nil { + return option.Outbound{}, E.New("invalid link") + } + + scheme := result[1] + switch scheme { + case "tuic": + return parseTuicLink(link) + case "trojan": + return parseTrojanLink(link) + case "vless": + return parseVLESSLink(link) + case "hysteria": + return parseHysteriaLink(link) + case "hy2", "hysteria2": + return parseHysteria2Link(link) + } + result[3], _ = common.DecodeBase64URLSafe(result[3]) + link = strings.Join(result[1:], "") + switch scheme { + case "ss": + return parseShadowsocksLink(link) + case "vmess": + return parseVMessLink(link) + default: + return option.Outbound{}, E.New("unsupported scheme: ", scheme) + } +} diff --git a/parser/link/shadowsocks.go b/parser/link/shadowsocks.go new file mode 100644 index 00000000..d22df3f1 --- /dev/null +++ b/parser/link/shadowsocks.go @@ -0,0 +1,39 @@ +package link + +import ( + "net/url" + + "github.com/sagernet/sing-box/common" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func parseShadowsocksLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + if linkURL.User == nil || linkURL.User.Username() == "" { + return option.Outbound{}, E.New("missing user info") + } + var options option.ShadowsocksOutboundOptions + options.ServerOptions.Server = linkURL.Hostname() + options.ServerOptions.ServerPort = common.StringToType[uint16](linkURL.Port()) + password, _ := linkURL.User.Password() + if password == "" { + return option.Outbound{}, E.New("bad user info") + } + options.Method = linkURL.User.Username() + options.Password = password + plugin := linkURL.Query().Get("plugin") + options.Plugin = shadowsocksPluginName(plugin) + options.PluginOptions = shadowsocksPluginOptions(plugin) + + outbound := option.Outbound{ + Type: C.TypeShadowsocks, + Tag: linkURL.Fragment, + } + outbound.Options = &options + return outbound, nil +} diff --git a/parser/link/trojan.go b/parser/link/trojan.go new file mode 100644 index 00000000..bca7ddf3 --- /dev/null +++ b/parser/link/trojan.go @@ -0,0 +1,89 @@ +package link + +import ( + "net/url" + "strings" + + "github.com/sagernet/sing-box/common" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" +) + +func parseTrojanLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + if linkURL.User == nil || linkURL.User.Username() == "" { + return option.Outbound{}, E.New("missing password") + } + var options option.TrojanOutboundOptions + TLSOptions := option.OutboundTLSOptions{ + Enabled: true, + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + options.Server = linkURL.Hostname() + TLSOptions.ServerName = linkURL.Hostname() + options.ServerPort = common.StringToType[uint16](linkURL.Port()) + options.Password = linkURL.User.Username() + proxy := map[string]string{} + for key, values := range linkURL.Query() { + value := values[0] + proxy[key] = value + } + for key, value := range proxy { + switch key { + case "insecure", "allowInsecure", "skip-cert-verify": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + case "serviceName", "sni", "peer": + TLSOptions.ServerName = value + case "alpn": + TLSOptions.ALPN = strings.Split(value, ",") + case "fp": + TLSOptions.UTLS.Enabled = true + TLSOptions.UTLS.Fingerprint = value + case "type": + Transport := option.V2RayTransportOptions{ + Type: "", + WebsocketOptions: option.V2RayWebsocketOptions{ + Headers: map[string]badoption.Listable[string]{}, + }, + HTTPOptions: option.V2RayHTTPOptions{ + Host: badoption.Listable[string]{}, + Headers: map[string]badoption.Listable[string]{}, + }, + GRPCOptions: option.V2RayGRPCOptions{}, + } + switch value { + case "ws": + Transport.Type = C.V2RayTransportTypeWebsocket + Transport.WebsocketOptions = v2rayTransportWs(proxy["host"], proxy["path"]) + case "grpc": + Transport.Type = C.V2RayTransportTypeGRPC + if serviceName, exists := proxy["grpc-service-name"]; exists && serviceName != "" { + Transport.GRPCOptions.ServiceName = serviceName + } + default: + continue + } + options.Transport = &Transport + case "tfo", "tcp-fast-open", "tcp_fast_open": + if value == "1" || value == "true" { + options.TCPFastOpen = true + } + } + } + outbound := option.Outbound{ + Type: C.TypeTrojan, + Tag: linkURL.Fragment, + } + options.TLS = &TLSOptions + outbound.Options = &options + return outbound, nil +} diff --git a/parser/link/tuic.go b/parser/link/tuic.go new file mode 100644 index 00000000..1cf1e29c --- /dev/null +++ b/parser/link/tuic.go @@ -0,0 +1,81 @@ +package link + +import ( + "net/url" + "strings" + + "github.com/sagernet/sing-box/common" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" +) + +func parseTuicLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + if linkURL.User == nil || linkURL.User.Username() == "" { + return option.Outbound{}, E.New("missing uuid") + } + var options option.TUICOutboundOptions + TLSOptions := option.OutboundTLSOptions{ + Enabled: true, + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + options.UUID = linkURL.User.Username() + options.Password, _ = linkURL.User.Password() + options.ServerOptions.Server = linkURL.Hostname() + TLSOptions.ServerName = linkURL.Hostname() + options.ServerOptions.ServerPort = common.StringToType[uint16](linkURL.Port()) + for key, values := range linkURL.Query() { + value := values[0] + switch key { + case "congestion_control": + if value != "cubic" { + options.CongestionControl = value + } + case "udp_relay_mode": + options.UDPRelayMode = value + case "udp_over_stream": + if value == "true" || value == "1" { + options.UDPOverStream = true + } + case "zero_rtt_handshake", "reduce_rtt": + if value == "true" || value == "1" { + options.ZeroRTTHandshake = true + } + case "heartbeat_interval": + options.Heartbeat = common.StringToType[badoption.Duration](value) + case "sni": + TLSOptions.ServerName = value + case "insecure", "skip-cert-verify", "allow_insecure": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + case "disable_sni": + if value == "1" || value == "true" { + TLSOptions.DisableSNI = true + } + case "tfo", "tcp-fast-open", "tcp_fast_open": + if value == "1" || value == "true" { + options.TCPFastOpen = true + } + case "alpn": + TLSOptions.ALPN = strings.Split(value, ",") + } + } + if options.UDPOverStream { + options.UDPRelayMode = "" + } + outbound := option.Outbound{ + Type: C.TypeTUIC, + Tag: linkURL.Fragment, + } + options.TLS = &TLSOptions + outbound.Options = &options + return outbound, nil +} diff --git a/parser/link/utils.go b/parser/link/utils.go new file mode 100644 index 00000000..bb72cd4d --- /dev/null +++ b/parser/link/utils.go @@ -0,0 +1,46 @@ +package link + +import ( + "regexp" + "strings" + + "github.com/sagernet/sing-box/common" + "github.com/sagernet/sing-box/option" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json/badoption" +) + +func shadowsocksPluginName(plugin string) string { + if index := strings.Index(plugin, ";"); index != -1 { + return plugin[:index] + } + return plugin +} + +func shadowsocksPluginOptions(plugin string) string { + if index := strings.Index(plugin, ";"); index != -1 { + return plugin[index+1:] + } + return "" +} + +func v2rayTransportWsPath(WebsocketOptions *option.V2RayWebsocketOptions, path string) { + reg := regexp.MustCompile(`^(.*?)(?:\?ed=(\d*))?$`) + result := reg.FindStringSubmatch(path) + WebsocketOptions.Path = result[1] + if result[2] != "" { + WebsocketOptions.EarlyDataHeaderName = "Sec-WebSocket-Protocol" + WebsocketOptions.MaxEarlyData = common.StringToType[uint32](result[2]) + } +} + +func v2rayTransportWs(host string, path string) option.V2RayWebsocketOptions { + var WebsocketOptions option.V2RayWebsocketOptions + if host != "" { + WebsocketOptions.Headers = common.StringToType[badoption.HTTPHeader](F.ToString("Host: ", host)) + } + if path != "" { + v2rayTransportWsPath(&WebsocketOptions, path) + } + return WebsocketOptions +} diff --git a/parser/link/vless.go b/parser/link/vless.go new file mode 100644 index 00000000..80442e1e --- /dev/null +++ b/parser/link/vless.go @@ -0,0 +1,114 @@ +package link + +import ( + "net/url" + "strings" + + "github.com/sagernet/sing-box/common" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" +) + +func parseVLESSLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + if linkURL.User == nil || linkURL.User.Username() == "" { + return option.Outbound{}, E.New("missing uuid") + } + var options option.VLESSOutboundOptions + TLSOptions := option.OutboundTLSOptions{ + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + options.UUID = linkURL.User.Username() + options.Server = linkURL.Hostname() + TLSOptions.ServerName = linkURL.Hostname() + options.ServerPort = common.StringToType[uint16](linkURL.Port()) + proxy := map[string]string{} + for key, values := range linkURL.Query() { + value := values[0] + switch key { + case "key", "alpn", "seed", "path", "host": + proxy[key] = value + default: + proxy[key] = value + } + } + for key, value := range proxy { + switch key { + case "type": + Transport := option.V2RayTransportOptions{ + HTTPOptions: option.V2RayHTTPOptions{ + Host: badoption.Listable[string]{}, + Headers: badoption.HTTPHeader{}, + }, + GRPCOptions: option.V2RayGRPCOptions{}, + } + switch value { + case "ws": + Transport.Type = C.V2RayTransportTypeWebsocket + Transport.WebsocketOptions = v2rayTransportWs(proxy["host"], proxy["path"]) + case "http": + Transport.Type = C.V2RayTransportTypeHTTP + if host, exists := proxy["host"]; exists && host != "" { + Transport.HTTPOptions.Host = strings.Split(host, ",") + } + if path, exists := proxy["path"]; exists && path != "" { + Transport.HTTPOptions.Path = path + } + case "grpc": + Transport.Type = C.V2RayTransportTypeGRPC + if serviceName, exists := proxy["serviceName"]; exists && serviceName != "" { + Transport.GRPCOptions.ServiceName = serviceName + } + default: + continue + } + options.Transport = &Transport + case "security": + if value == "tls" { + TLSOptions.Enabled = true + } else if value == "reality" { + TLSOptions.Enabled = true + TLSOptions.Reality.Enabled = true + } + case "insecure", "skip-cert-verify": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + case "serviceName", "sni", "peer": + TLSOptions.ServerName = value + case "alpn": + TLSOptions.ALPN = strings.Split(value, ",") + case "fp": + TLSOptions.UTLS.Enabled = true + TLSOptions.UTLS.Fingerprint = value + case "flow": + if value == "xtls-rprx-vision" { + options.Flow = "xtls-rprx-vision" + } + case "pbk": + TLSOptions.Reality.PublicKey = value + case "sid": + TLSOptions.Reality.ShortID = value + case "tfo", "tcp-fast-open", "tcp_fast_open": + if value == "1" || value == "true" { + options.TCPFastOpen = true + } + } + } + outbound := option.Outbound{ + Type: C.TypeVLESS, + Tag: linkURL.Fragment, + } + if TLSOptions.Enabled { + options.TLS = &TLSOptions + } + outbound.Options = &options + return outbound, nil +} diff --git a/parser/link/vmess.go b/parser/link/vmess.go new file mode 100644 index 00000000..d3a09711 --- /dev/null +++ b/parser/link/vmess.go @@ -0,0 +1,160 @@ +package link + +import ( + "encoding/json" + "net/url" + "regexp" + "strconv" + + "github.com/sagernet/sing-box/common" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" +) + +func parseVMessLink(link string) (option.Outbound, error) { + var proxy map[string]string + reg := regexp.MustCompile(`(\"[^:,]+?\"[ \t]*:[ \t]*)(\d+|true|false)`) + s := reg.ReplaceAllString(link, `$1"$2"`) + err := json.Unmarshal([]byte(s[8:]), &proxy) + if err != nil { + proxy = make(map[string]string) + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + if linkURL.User == nil || linkURL.User.Username() == "" { + return option.Outbound{}, E.New("missing uuid") + } + proxy["id"] = linkURL.User.Username() + proxy["add"] = linkURL.Hostname() + proxy["port"] = linkURL.Port() + proxy["ps"] = linkURL.Fragment + for key, values := range linkURL.Query() { + value := values[0] + switch key { + case "type": + if value == "http" { + proxy["net"] = "tcp" + proxy["type"] = "http" + } + case "encryption": + proxy["scy"] = value + case "alterId": + proxy["aid"] = value + case "key", "alpn", "seed", "path", "host": + proxy[key] = value + default: + proxy[key] = value + } + } + } + outbound := option.Outbound{ + Type: C.TypeVMess, + } + options := option.VMessOutboundOptions{ + Security: "auto", + } + TLSOptions := option.OutboundTLSOptions{ + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + for key, value := range proxy { + switch key { + case "ps": + outbound.Tag = value + case "add": + options.Server = value + TLSOptions.ServerName = value + case "port": + options.ServerPort = common.StringToType[uint16](value) + case "id": + options.UUID = value + case "scy": + options.Security = value + case "aid": + options.AlterId, _ = strconv.Atoi(value) + case "packet_encoding": + options.PacketEncoding = value + case "xudp": + if value == "1" || value == "true" { + options.PacketEncoding = "xudp" + } + case "tls": + if value == "1" || value == "true" || value == "tls" { + TLSOptions.Enabled = true + } + case "insecure", "skip-cert-verify": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + case "fp": + TLSOptions.UTLS.Enabled = true + TLSOptions.UTLS.Fingerprint = value + case "net": + Transport := option.V2RayTransportOptions{ + Type: "", + WebsocketOptions: option.V2RayWebsocketOptions{ + Headers: badoption.HTTPHeader{}, + }, + HTTPOptions: option.V2RayHTTPOptions{ + Host: badoption.Listable[string]{}, + Headers: map[string]badoption.Listable[string]{}, + }, + GRPCOptions: option.V2RayGRPCOptions{}, + } + switch value { + case "ws": + Transport.Type = C.V2RayTransportTypeWebsocket + Transport.WebsocketOptions = v2rayTransportWs(proxy["host"], proxy["path"]) + case "h2": + Transport.Type = C.V2RayTransportTypeHTTP + TLSOptions.Enabled = true + if host, exists := proxy["host"]; exists && host != "" { + Transport.HTTPOptions.Host = []string{host} + } + if path, exists := proxy["path"]; exists && path != "" { + Transport.HTTPOptions.Path = path + } + case "tcp": + if tType, exists := proxy["type"]; exists { + if tType != "http" { + continue + } + Transport.Type = C.V2RayTransportTypeHTTP + if method, exists := proxy["method"]; exists { + Transport.HTTPOptions.Method = method + } + if host, exists := proxy["host"]; exists && host != "" { + Transport.HTTPOptions.Host = []string{host} + } + if path, exists := proxy["path"]; exists && path != "" { + Transport.HTTPOptions.Path = path + } + if headers, exists := proxy["headers"]; exists { + Transport.HTTPOptions.Headers = common.StringToType[badoption.HTTPHeader](headers) + } + } + case "grpc": + Transport.Type = C.V2RayTransportTypeGRPC + if host, exists := proxy["host"]; exists && host != "" { + Transport.GRPCOptions.ServiceName = host + } + default: + continue + } + options.Transport = &Transport + case "tfo", "tcp-fast-open", "tcp_fast_open": + if value == "1" || value == "true" { + options.TCPFastOpen = true + } + } + } + if TLSOptions.Enabled { + options.TLS = &TLSOptions + } + outbound.Options = &options + return outbound, nil +} diff --git a/parser/parser.go b/parser/parser.go new file mode 100644 index 00000000..32da0064 --- /dev/null +++ b/parser/parser.go @@ -0,0 +1,31 @@ +package parser + +import ( + "context" + + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/parser/clash" + "github.com/sagernet/sing-box/parser/raw" + "github.com/sagernet/sing-box/parser/singbox" + "github.com/sagernet/sing-box/parser/sip008" + E "github.com/sagernet/sing/common/exceptions" +) + +var subscriptionParsers = []func(ctx context.Context, content string) ([]option.Outbound, error){ + singbox.ParseBoxSubscription, + clash.ParseClashSubscription, + sip008.ParseSIP008Subscription, + raw.ParseRawSubscription, +} + +func ParseSubscription(ctx context.Context, content string) ([]option.Outbound, error) { + var pErr error + for _, parser := range subscriptionParsers { + servers, err := parser(ctx, content) + if len(servers) > 0 { + return servers, nil + } + pErr = E.Errors(pErr, err) + } + return nil, E.Cause(pErr, "no servers found") +} diff --git a/parser/raw/parser.go b/parser/raw/parser.go new file mode 100644 index 00000000..4c8fe2d8 --- /dev/null +++ b/parser/raw/parser.go @@ -0,0 +1,50 @@ +package raw + +import ( + "context" + "encoding/base64" + "strings" + + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/parser/link" + E "github.com/sagernet/sing/common/exceptions" +) + +func ParseRawSubscription(ctx context.Context, content string) ([]option.Outbound, error) { + if base64Content, err := DecodeBase64URLSafe(content); err == nil { + servers, _ := parseRawSubscription(base64Content) + if len(servers) > 0 { + return servers, err + } + } + return parseRawSubscription(content) +} + +func parseRawSubscription(content string) ([]option.Outbound, error) { + var servers []option.Outbound + content = strings.ReplaceAll(content, "\r\n", "\n") + linkList := strings.Split(content, "\n") + for _, linkLine := range linkList { + server, err := link.ParseSubscriptionLink(linkLine) + if err != nil { + continue + } + servers = append(servers, server) + } + if len(servers) == 0 { + return nil, E.New("no servers found") + } + return servers, nil +} + +func DecodeBase64URLSafe(content string) (string, error) { + s := strings.ReplaceAll(content, " ", "-") + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "+", "-") + s = strings.ReplaceAll(s, "=", "") + result, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return content, nil + } + return string(result), nil +} diff --git a/parser/singbox/parser.go b/parser/singbox/parser.go new file mode 100644 index 00000000..f5d3ed5a --- /dev/null +++ b/parser/singbox/parser.go @@ -0,0 +1,58 @@ +package singbox + +import ( + "context" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" +) + +type _SingBoxDocument struct { + Outbounds []option.Outbound `json:"outbounds"` +} +type SingBoxDocument _SingBoxDocument + +func (o *SingBoxDocument) UnmarshalJSONContext(ctx context.Context, inputContent []byte) error { + var content badjson.JSONObject + err := content.UnmarshalJSONContext(ctx, inputContent) + if err != nil { + return err + } + outbounds, ok := content.Get("outbounds") + if !ok { + return E.New("missing outbounds in sing-box configuration") + } + var outs badjson.JSONArray + for i, outbound := range outbounds.(badjson.JSONArray) { + typeVal, loaded := outbound.(*badjson.JSONObject).Get("type") + if !loaded { + return E.New("missing type in outbound[", i, "]") + } + switch typeVal.(string) { + case C.TypeDirect, C.TypeBlock, C.TypeDNS, C.TypeSelector, C.TypeURLTest: + continue + default: + outs = append(outs, outbound) + } + } + content.Put("outbounds", outs) + inputContent, err = content.MarshalJSONContext(ctx) + if err != nil { + return err + } + return json.UnmarshalContext(ctx, inputContent, (*_SingBoxDocument)(o)) +} + +func ParseBoxSubscription(ctx context.Context, content string) ([]option.Outbound, error) { + options, err := json.UnmarshalExtendedContext[SingBoxDocument](ctx, []byte(content)) + if err != nil { + return nil, err + } + if len(options.Outbounds) == 0 { + return nil, E.New("no servers found") + } + return options.Outbounds, nil +} diff --git a/parser/sip008/parser.go b/parser/sip008/parser.go new file mode 100644 index 00000000..ad55d062 --- /dev/null +++ b/parser/sip008/parser.go @@ -0,0 +1,53 @@ +package sip008 + +import ( + "context" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" +) + +type ShadowsocksDocument struct { + Version int `json:"version"` + Servers []ShadowsocksServerDocument `json:"servers"` +} + +type ShadowsocksServerDocument struct { + ID string `json:"id"` + Remarks string `json:"remarks"` + Server string `json:"server"` + ServerPort int `json:"server_port"` + Password string `json:"password"` + Method string `json:"method"` + Plugin string `json:"plugin"` + PluginOpts string `json:"plugin_opts"` +} + +func ParseSIP008Subscription(_ context.Context, content string) ([]option.Outbound, error) { + var document ShadowsocksDocument + err := json.Unmarshal([]byte(content), &document) + if err != nil { + return nil, E.Cause(err, "parse SIP008 document") + } + + var servers []option.Outbound + for _, server := range document.Servers { + servers = append(servers, option.Outbound{ + Type: C.TypeShadowsocks, + Tag: server.Remarks, + Options: &option.ShadowsocksOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: server.Server, + ServerPort: uint16(server.ServerPort), + }, + Password: server.Password, + Method: server.Method, + Plugin: server.Plugin, + PluginOptions: server.PluginOpts, + }, + }) + } + return servers, nil +} diff --git a/protocol/bond/conn.go b/protocol/bond/conn.go new file mode 100644 index 00000000..992e5187 --- /dev/null +++ b/protocol/bond/conn.go @@ -0,0 +1,159 @@ +package bond + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "net" + "time" +) + +type bondedConn struct { + conns []net.Conn + downloadRatios []uint8 + uploadRatios []uint8 + + readBuffer *bytes.Buffer +} + +func NewBondedConn(conns []net.Conn, downloadRatios, uploadRatios []uint8) *bondedConn { + return &bondedConn{ + conns: conns, + downloadRatios: downloadRatios, + uploadRatios: uploadRatios, + readBuffer: bytes.NewBuffer(make([]byte, 0, 65536)), + } +} + +func (c *bondedConn) Read(b []byte) (n int, err error) { + if c.readBuffer.Len() == 0 { + c.readBuffer.Reset() + 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 + } + n, err := io.CopyN(c.readBuffer, c.conns[i], int64(chunkLen)) + total += int(n) + if err != nil { + return total, err + } + } + } + return c.readBuffer.Read(b) +} + +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..89796c4b --- /dev/null +++ b/protocol/bond/inbound.go @@ -0,0 +1,150 @@ +package bond + +import ( + "context" + "errors" + "net" + "time" + + "github.com/gofrs/uuid/v5" + "github.com/patrickmn/go-cache/v2" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/kmutex" + "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[uuid.UUID, map[uint8]*ratioConn] + + mtx *kmutex.Kmutex[uuid.UUID] +} + +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.TypeBond, tag), + logger: logger, + router: uot.NewRouter(router, logger), + conns: cache.New[uuid.UUID, map[uint8]*ratioConn](C.TCPConnectTimeout, time.Second), + mtx: kmutex.New[uuid.UUID](), + } + router = NewRouter(router, logger, inbound.connHandler) + inboundRegistry := service.FromContext[adapter.InboundRegistry](ctx) + inbounds := make([]adapter.Inbound, len(options.Inbounds)) + for i, inboundOptions := range options.Inbounds { + inbound, err := inboundRegistry.UnsafeCreate(ctx, router, logger, inboundOptions.Tag, inboundOptions.Type, inboundOptions.Options) + if err != nil { + return nil, err + } + inbounds[i] = inbound + } + inbound.inbounds = inbounds + inbound.conns.OnEvicted(func(s uuid.UUID, ratioConns map[uint8]*ratioConn) { + inbound.mtx.Lock(s) + defer inbound.mtx.Unlock(s) + 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 + } + requestUUID := request.UUID + h.mtx.Lock(requestUUID) + var ratioConns map[uint8]*ratioConn + ratioConns, ok := h.conns.Get(requestUUID) + if !ok { + ratioConns = make(map[uint8]*ratioConn, request.Count) + h.conns.SetDefault(requestUUID, 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() + } + h.mtx.Unlock(requestUUID) + return E.New("invalid ratios") + } + conn = NewBondedConn(conns, downloadRatios, uploadRatios) + metadata.Inbound = h.Tag() + metadata.InboundType = C.TypeBond + metadata.Destination = request.Destination + h.mtx.Unlock(requestUUID) + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) + return nil + } + h.mtx.Unlock(requestUUID) + 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..01862e1a --- /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.TypeBond, 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/tunnel/protocol.go b/protocol/bond/protocol.go similarity index 67% rename from protocol/tunnel/protocol.go rename to protocol/bond/protocol.go index 19e6f1cd..5875d0ed 100644 --- a/protocol/tunnel/protocol.go +++ b/protocol/bond/protocol.go @@ -1,4 +1,4 @@ -package tunnel +package bond import ( "encoding/binary" @@ -15,13 +15,8 @@ const ( Version = 0 ) -const ( - CommandInbound = 1 - CommandTCP = 2 -) - var Destination = M.Socksaddr{ - Fqdn: "sp.tunnel.sing-box.arpa", + Fqdn: "sp.bond.sing-box.arpa", Port: 444, } @@ -33,10 +28,12 @@ var AddressSerializer = M.NewSerializer( ) type Request struct { - UUID uuid.UUID - Command byte - DestinationUUID uuid.UUID - Destination M.Socksaddr + UUID uuid.UUID + Index byte + Count byte + DownloadRatio byte + UploadRatio byte + Destination M.Socksaddr } func ReadRequest(reader io.Reader) (*Request, error) { @@ -53,11 +50,19 @@ func ReadRequest(reader io.Reader) (*Request, error) { if err != nil { return nil, err } - err = binary.Read(reader, binary.BigEndian, &request.Command) + err = binary.Read(reader, binary.BigEndian, &request.Index) if err != nil { return nil, err } - _, err = io.ReadFull(reader, request.DestinationUUID[:]) + 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 } @@ -72,16 +77,20 @@ func WriteRequest(writer io.Writer, request *Request) error { var requestLen int requestLen += 1 // version requestLen += 16 // UUID - requestLen += 16 // destinationUUID - requestLen += 1 // command + 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.Command), - common.Error(buffer.Write(request.DestinationUUID[:])), + 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 { diff --git a/protocol/bond/router.go b/protocol/bond/router.go new file mode 100644 index 00000000..f2ba1283 --- /dev/null +++ b/protocol/bond/router.go @@ -0,0 +1,55 @@ +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 { + if metadata.Destination != Destination { + return r.Router.RouteConnection(ctx, conn, metadata) + } + return r.handler(ctx, conn, metadata, func(error) {}) +} + +func (r *Router) RoutePacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error { + if metadata.Destination != Destination { + return r.Router.RoutePacketConnection(ctx, conn, metadata) + } + return os.ErrInvalid +} + +func (r *Router) RouteConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + if metadata.Destination != Destination { + r.Router.RouteConnectionEx(ctx, conn, metadata, onClose) + return + } + 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) { + if metadata.Destination != Destination { + r.Router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) + return + } + r.logger.ErrorContext(ctx, os.ErrInvalid) + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) +} diff --git a/protocol/group/fallback.go b/protocol/group/fallback.go new file mode 100644 index 00000000..57353362 --- /dev/null +++ b/protocol/group/fallback.go @@ -0,0 +1,109 @@ +package group + +import ( + "context" + "net" + "sync" + + "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 RegisterFallback(registry *outbound.Registry) { + outbound.Register[option.FallbackOutboundOptions](registry, C.TypeFallback, NewFallback) +} + +var ( + _ adapter.OutboundGroup = (*Fallback)(nil) +) + +type Fallback struct { + outbound.Adapter + ctx context.Context + outbound adapter.OutboundManager + logger logger.ContextLogger + tags []string + outbounds map[string]adapter.Outbound + lastUsedOutbound string + + mtx sync.Mutex +} + +func NewFallback(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.FallbackOutboundOptions) (adapter.Outbound, error) { + if len(options.Outbounds) == 0 { + return nil, E.New("missing tags") + } + outbound := &Fallback{ + Adapter: outbound.NewAdapter(C.TypeFallback, 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)), + lastUsedOutbound: options.Outbounds[0], + } + return outbound, nil +} + +func (s *Fallback) 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 *Fallback) Now() string { + s.mtx.Lock() + defer s.mtx.Unlock() + return s.lastUsedOutbound +} + +func (s *Fallback) All() []string { + return s.tags +} + +func (s *Fallback) 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 + } + s.mtx.Lock() + defer s.mtx.Unlock() + s.lastUsedOutbound = outbound.Tag() + return conn, nil + } + return nil, err +} + +func (s *Fallback) 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 + } + s.mtx.Lock() + defer s.mtx.Unlock() + s.lastUsedOutbound = outbound.Tag() + return conn, nil + } + return nil, err +} diff --git a/protocol/group/selector.go b/protocol/group/selector.go index 8a686e5b..29d56560 100644 --- a/protocol/group/selector.go +++ b/protocol/group/selector.go @@ -3,6 +3,7 @@ package group import ( "context" "net" + "regexp" "time" "github.com/sagernet/sing-box/adapter" @@ -42,11 +43,20 @@ type Selector struct { selected common.TypedValue[adapter.Outbound] interruptGroup *interrupt.Group interruptExternalConnections bool + + provider adapter.ProviderManager + providers map[string]adapter.Provider + outboundsCache map[string][]adapter.Outbound + + providerTags []string + exclude *regexp.Regexp + include *regexp.Regexp + useAllProviders bool } func NewSelector(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SelectorOutboundOptions) (adapter.Outbound, error) { outbound := &Selector{ - Adapter: outbound.NewAdapter(C.TypeSelector, tag, nil, options.Outbounds), + Adapter: outbound.NewAdapter(C.TypeSelector, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.Outbounds), ctx: ctx, outbound: service.FromContext[adapter.OutboundManager](ctx), connection: service.FromContext[adapter.ConnectionManager](ctx), @@ -56,9 +66,15 @@ func NewSelector(ctx context.Context, router adapter.Router, logger log.ContextL outbounds: make(map[string]adapter.Outbound), interruptGroup: interrupt.NewGroup(), interruptExternalConnections: options.InterruptExistConnections, - } - if len(outbound.tags) == 0 { - return nil, E.New("missing tags") + + provider: service.FromContext[adapter.ProviderManager](ctx), + providers: make(map[string]adapter.Provider), + outboundsCache: make(map[string][]adapter.Outbound), + + providerTags: options.Providers, + exclude: (*regexp.Regexp)(options.Exclude), + include: (*regexp.Regexp)(options.Include), + useAllProviders: options.UseAllProviders, } return outbound, nil } @@ -72,6 +88,28 @@ func (s *Selector) Network() []string { } func (s *Selector) Start() error { + if s.useAllProviders { + var providerTags []string + for _, provider := range s.provider.Providers() { + providerTags = append(providerTags, provider.Tag()) + s.providers[provider.Tag()] = provider + provider.RegisterCallback(s.onProviderUpdated) + } + s.providerTags = providerTags + } else { + for i, tag := range s.providerTags { + provider, loaded := s.provider.Get(tag) + if !loaded { + return E.New("outbound provider ", i, " not found: ", tag) + } + s.providers[tag] = provider + provider.RegisterCallback(s.onProviderUpdated) + } + } + if len(s.tags)+len(s.providerTags) == 0 { + return E.New("missing outbound and provider tags") + } + for i, tag := range s.tags { detour, loaded := s.outbound.Outbound(tag) if !loaded { @@ -79,31 +117,16 @@ func (s *Selector) Start() error { } s.outbounds[tag] = detour } - - if s.Tag() != "" { - cacheFile := service.FromContext[adapter.CacheFile](s.ctx) - if cacheFile != nil { - selected := cacheFile.LoadSelected(s.Tag()) - if selected != "" { - detour, loaded := s.outbounds[selected] - if loaded { - s.selected.Store(detour) - return nil - } - } - } + if len(s.tags) == 0 { + detour, _ := s.outbound.Outbound("Compatible") + s.tags = append(s.tags, detour.Tag()) + s.outbounds[detour.Tag()] = detour } - - if s.defaultTag != "" { - detour, loaded := s.outbounds[s.defaultTag] - if !loaded { - return E.New("default outbound not found: ", s.defaultTag) - } - s.selected.Store(detour) - return nil + outbound, err := s.outboundSelect() + if err != nil { + return err } - - s.selected.Store(s.outbounds[s.tags[0]]) + s.selected.Store(outbound) return nil } @@ -145,7 +168,7 @@ func (s *Selector) DialContext(ctx context.Context, network string, destination if err != nil { return nil, err } - return s.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx)), nil + return s.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)), nil } func (s *Selector) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { @@ -153,13 +176,13 @@ func (s *Selector) ListenPacket(ctx context.Context, destination M.Socksaddr) (n if err != nil { return nil, err } - return s.interruptGroup.NewPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx)), nil + return s.interruptGroup.NewPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)), nil } func (s *Selector) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { ctx = interrupt.ContextWithIsExternalConnection(ctx) selected := s.selected.Load() - conn = s.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx)) + conn = s.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)) if outboundHandler, isHandler := selected.(adapter.ConnectionHandlerEx); isHandler { outboundHandler.NewConnectionEx(ctx, conn, metadata, onClose) } else { @@ -170,7 +193,7 @@ func (s *Selector) NewConnectionEx(ctx context.Context, conn net.Conn, metadata func (s *Selector) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { ctx = interrupt.ContextWithIsExternalConnection(ctx) selected := s.selected.Load() - conn = s.interruptGroup.NewSingPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx)) + conn = s.interruptGroup.NewSingPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)) if outboundHandler, isHandler := selected.(adapter.PacketConnectionHandlerEx); isHandler { outboundHandler.NewPacketConnectionEx(ctx, conn, metadata, onClose) } else { @@ -192,3 +215,77 @@ func RealTag(detour adapter.Outbound) string { } return detour.Tag() } + +func (s *Selector) onProviderUpdated(tag string) error { + _, loaded := s.providers[tag] + if !loaded { + return E.New(s.Tag(), ": ", "outbound provider not found: ", tag) + } + var ( + tags = s.Dependencies() + outboundByTag = make(map[string]adapter.Outbound) + ) + for _, tag := range tags { + outboundByTag[tag] = s.outbounds[tag] + } + for _, providerTag := range s.providerTags { + if providerTag != tag && s.outboundsCache[providerTag] != nil { + for _, detour := range s.outboundsCache[providerTag] { + tags = append(tags, detour.Tag()) + outboundByTag[detour.Tag()] = detour + } + continue + } + provider := s.providers[providerTag] + var cache []adapter.Outbound + for _, detour := range provider.Outbounds() { + tag := detour.Tag() + if s.exclude != nil && s.exclude.MatchString(tag) { + continue + } + if s.include != nil && !s.include.MatchString(tag) { + continue + } + tags = append(tags, tag) + cache = append(cache, detour) + outboundByTag[tag] = detour + } + s.outboundsCache[providerTag] = cache + } + if len(tags) == 0 { + detour, _ := s.outbound.Outbound("Compatible") + tags = append(tags, detour.Tag()) + outboundByTag[detour.Tag()] = detour + } + s.tags, s.outbounds = tags, outboundByTag + detour, _ := s.outboundSelect() + if s.selected.Swap(detour) != detour { + s.interruptGroup.Interrupt(s.interruptExternalConnections) + } + return nil +} + +func (s *Selector) outboundSelect() (adapter.Outbound, error) { + if s.Tag() != "" { + cacheFile := service.FromContext[adapter.CacheFile](s.ctx) + if cacheFile != nil { + selected := cacheFile.LoadSelected(s.Tag()) + if selected != "" { + detour, loaded := s.outbounds[selected] + if loaded { + return detour, nil + } + } + } + } + + if s.defaultTag != "" { + detour, loaded := s.outbounds[s.defaultTag] + if !loaded { + return nil, E.New("default outbound not found: ", s.defaultTag) + } + return detour, nil + } + + return s.outbounds[s.tags[0]], nil +} diff --git a/protocol/group/urltest.go b/protocol/group/urltest.go index 91964aa0..4b20c629 100644 --- a/protocol/group/urltest.go +++ b/protocol/group/urltest.go @@ -3,6 +3,7 @@ package group import ( "context" "net" + "regexp" "sync" "sync/atomic" "time" @@ -45,6 +46,16 @@ type URLTest struct { idleTimeout time.Duration group *URLTestGroup interruptExternalConnections bool + + provider adapter.ProviderManager + providers map[string]adapter.Provider + outboundsCache map[string][]adapter.Outbound + cancel context.CancelFunc + + providerTags []string + exclude *regexp.Regexp + include *regexp.Regexp + useAllProviders bool } func NewURLTest(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.URLTestOutboundOptions) (adapter.Outbound, error) { @@ -61,14 +72,42 @@ func NewURLTest(ctx context.Context, router adapter.Router, logger log.ContextLo tolerance: options.Tolerance, idleTimeout: time.Duration(options.IdleTimeout), interruptExternalConnections: options.InterruptExistConnections, - } - if len(outbound.tags) == 0 { - return nil, E.New("missing tags") + + provider: service.FromContext[adapter.ProviderManager](ctx), + providers: make(map[string]adapter.Provider), + outboundsCache: make(map[string][]adapter.Outbound), + + providerTags: options.Providers, + exclude: (*regexp.Regexp)(options.Exclude), + include: (*regexp.Regexp)(options.Include), + useAllProviders: options.UseAllProviders, } return outbound, nil } func (s *URLTest) Start() error { + if s.useAllProviders { + var providerTags []string + for _, provider := range s.provider.Providers() { + providerTags = append(providerTags, provider.Tag()) + s.providers[provider.Tag()] = provider + provider.RegisterCallback(s.onProviderUpdated) + } + s.providerTags = providerTags + } else { + for i, tag := range s.providerTags { + provider, loaded := s.provider.Get(tag) + if !loaded { + return E.New("outbound provider ", i, " not found: ", tag) + } + s.providers[tag] = provider + provider.RegisterCallback(s.onProviderUpdated) + } + } + if len(s.tags)+len(s.providerTags) == 0 { + return E.New("missing outbound and provider tags") + } + outbounds := make([]adapter.Outbound, 0, len(s.tags)) for i, tag := range s.tags { detour, loaded := s.outbound.Outbound(tag) @@ -77,6 +116,11 @@ func (s *URLTest) Start() error { } outbounds = append(outbounds, detour) } + if len(s.tags) == 0 { + detour, _ := s.outbound.Outbound("Compatible") + s.tags = append(s.tags, detour.Tag()) + outbounds = append(outbounds, detour) + } group, err := NewURLTestGroup(s.ctx, s.outbound, s.logger, outbounds, s.link, s.interval, s.tolerance, s.idleTimeout, s.interruptExternalConnections) if err != nil { return err @@ -136,7 +180,7 @@ func (s *URLTest) DialContext(ctx context.Context, network string, destination M } conn, err := outbound.DialContext(ctx, network, destination) if err == nil { - return s.group.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx)), nil + return s.group.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)), nil } s.logger.ErrorContext(ctx, err) s.group.history.DeleteURLTestHistory(outbound.Tag()) @@ -154,7 +198,7 @@ func (s *URLTest) ListenPacket(ctx context.Context, destination M.Socksaddr) (ne } conn, err := outbound.ListenPacket(ctx, destination) if err == nil { - return s.group.interruptGroup.NewPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx)), nil + return s.group.interruptGroup.NewPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)), nil } s.logger.ErrorContext(ctx, err) s.group.history.DeleteURLTestHistory(outbound.Tag()) @@ -163,13 +207,13 @@ func (s *URLTest) ListenPacket(ctx context.Context, destination M.Socksaddr) (ne func (s *URLTest) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { ctx = interrupt.ContextWithIsExternalConnection(ctx) - conn = s.group.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx)) + conn = s.group.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)) s.connection.NewConnection(ctx, s, conn, metadata, onClose) } func (s *URLTest) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { ctx = interrupt.ContextWithIsExternalConnection(ctx) - conn = s.group.interruptGroup.NewSingPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx)) + conn = s.group.interruptGroup.NewSingPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)) s.connection.NewPacketConnection(ctx, s, conn, metadata, onClose) } @@ -188,6 +232,63 @@ func (s *URLTest) NewDirectRouteConnection(metadata adapter.InboundContext, rout return selected.(adapter.DirectRouteOutbound).NewDirectRouteConnection(metadata, routeContext, timeout) } +func (s *URLTest) onProviderUpdated(tag string) error { + _, loaded := s.providers[tag] + if !loaded { + return E.New("outbound provider not found: ", tag) + } + var ( + tags = s.Dependencies() + outbounds []adapter.Outbound + ) + for _, tag := range tags { + detour, _ := s.outbound.Outbound(tag) + outbounds = append(outbounds, detour) + } + for _, providerTag := range s.providerTags { + if providerTag != tag && s.outboundsCache[providerTag] != nil { + for _, detour := range s.outboundsCache[providerTag] { + tags = append(tags, detour.Tag()) + outbounds = append(outbounds, detour) + } + continue + } + provider := s.providers[providerTag] + var cache []adapter.Outbound + for _, detour := range provider.Outbounds() { + tag := detour.Tag() + if s.exclude != nil && s.exclude.MatchString(tag) { + continue + } + if s.include != nil && !s.include.MatchString(tag) { + continue + } + tags = append(tags, tag) + cache = append(cache, detour) + } + outbounds = append(outbounds, cache...) + s.outboundsCache[providerTag] = cache + } + if len(tags) == 0 { + detour, _ := s.outbound.Outbound("Compatible") + tags = append(tags, detour.Tag()) + outbounds = append(outbounds, detour) + } + s.tags, s.group.outbounds = tags, outbounds + s.group.access.Lock() + if s.group.ticker != nil { + s.group.ticker.Reset(s.group.interval) + } + s.group.access.Unlock() + ctx, cancel := context.WithCancel(s.ctx) + if s.cancel != nil { + s.cancel() + } + s.cancel = cancel + s.URLTest(ctx) + return nil +} + type URLTestGroup struct { ctx context.Context router adapter.Router @@ -407,7 +508,11 @@ func (g *URLTestGroup) urlTest(ctx context.Context, force bool) (map[string]uint }) } b.Wait() - g.performUpdateCheck() + select { + case <-ctx.Done(): + default: + g.performUpdateCheck() + } return result, nil } diff --git a/protocol/hysteria/inbound.go b/protocol/hysteria/inbound.go index 98d7cb81..f0dea85c 100644 --- a/protocol/hysteria/inbound.go +++ b/protocol/hysteria/inbound.go @@ -178,3 +178,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 bb598070..1017a5e6 100644 --- a/protocol/hysteria2/inbound.go +++ b/protocol/hysteria2/inbound.go @@ -211,3 +211,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/conn.go b/protocol/limiter/bandwidth/conn.go new file mode 100644 index 00000000..06796e27 --- /dev/null +++ b/protocol/limiter/bandwidth/conn.go @@ -0,0 +1,119 @@ +package bandwidth + +import ( + "context" + "net" + + "golang.org/x/time/rate" +) + +type connWithDownloadBandwidthLimiter struct { + net.Conn + ctx context.Context + limiter Limiter +} + +func NewConnWithDownloadBandwidthLimiter(ctx context.Context, conn net.Conn, limiter *rate.Limiter) *connWithDownloadBandwidthLimiter { + return &connWithDownloadBandwidthLimiter{conn, ctx, limiter} +} + +func (conn *connWithDownloadBandwidthLimiter) Write(p []byte) (n int, err error) { + err = conn.limiter.WaitN(conn.ctx, len(p)) + if err != nil { + return + } + return conn.Conn.Write(p) +} + +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) { + n, err = conn.Conn.Read(p) + if err != nil { + return + } + err = conn.limiter.WaitN(conn.ctx, n) + if err != nil { + return + } + return n, err +} + +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) { + err = conn.limiter.WaitN(conn.ctx, len(p)) + if err != nil { + return + } + return conn.PacketConn.WriteTo(p, addr) +} + +type packetConnWithUploadBandwidthLimiter struct { + net.PacketConn + ctx context.Context + limiter 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) { + 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/limiter.go b/protocol/limiter/bandwidth/limiter.go new file mode 100644 index 00000000..95655b3c --- /dev/null +++ b/protocol/limiter/bandwidth/limiter.go @@ -0,0 +1,9 @@ +package bandwidth + +import ( + "context" +) + +type Limiter interface { + WaitN(ctx context.Context, n int) (err error) +} 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..92db1f64 --- /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)), 65536) +} + +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/masque/config.go b/protocol/masque/config.go new file mode 100644 index 00000000..11aa52da --- /dev/null +++ b/protocol/masque/config.go @@ -0,0 +1,89 @@ +package masque + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "net" +) + +type Config struct { + PrivateKey string `json:"private_key"` // Base64-encoded ECDSA private key + EndpointV4 string `json:"endpoint_v4"` // IPv4 address of the endpoint + EndpointV6 string `json:"endpoint_v6"` // IPv6 address of the endpoint + EndpointH2V4 string `json:"endpoint_h2_v4"` // IPv4 address used in HTTP/2 mode + EndpointH2V6 string `json:"endpoint_h2_v6"` // IPv6 address used in HTTP/2 mode + EndpointPubKey string `json:"endpoint_pub_key"` // PEM-encoded ECDSA public key of the endpoint to verify against + License string `json:"license"` // Application license key + ID string `json:"id"` // Device unique identifier + AccessToken string `json:"access_token"` // Authentication token for API access + IPv4 string `json:"ipv4"` // Assigned IPv4 address + IPv6 string `json:"ipv6"` // Assigned IPv6 address +} + +func (c *Config) GetEcPrivateKey() (*ecdsa.PrivateKey, error) { + privKeyB64, err := base64.StdEncoding.DecodeString(c.PrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to decode private key: %v", err) + } + privKey, err := x509.ParseECPrivateKey(privKeyB64) + if err != nil { + return nil, fmt.Errorf("failed to parse private key: %v", err) + } + return privKey, nil +} + +func (c *Config) GetEcEndpointPublicKey() (*ecdsa.PublicKey, error) { + endpointPubKeyB64, _ := pem.Decode([]byte(c.EndpointPubKey)) + if endpointPubKeyB64 == nil { + return nil, fmt.Errorf("failed to decode endpoint public key") + } + + pubKey, err := x509.ParsePKIXPublicKey(endpointPubKeyB64.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse public key: %v", err) + } + + ecPubKey, ok := pubKey.(*ecdsa.PublicKey) + if !ok { + return nil, fmt.Errorf("failed to assert public key as ECDSA") + } + + return ecPubKey, nil +} + +func (c *Config) SelectEndpointFromConfig(useHTTP2 bool, useIPv6 bool, port int) (net.Addr, error) { + if useHTTP2 { + if useIPv6 { + if c.EndpointH2V6 == "" { + return nil, fmt.Errorf("--http2 with --ipv6 requires config endpoint_h2_v6 to be set") + } + ip := net.ParseIP(c.EndpointH2V6) + if ip == nil { + return nil, fmt.Errorf("invalid endpoint_h2_v6 value %q", c.EndpointH2V6) + } + + return &net.TCPAddr{IP: ip, Port: port}, nil + } + v4 := c.EndpointH2V4 + ip := net.ParseIP(v4) + if ip == nil { + return nil, fmt.Errorf("invalid endpoint_h2_v4 value %q") + } + return &net.TCPAddr{IP: ip, Port: port}, nil + } + if useIPv6 { + ip := net.ParseIP(c.EndpointV6) + if ip == nil { + return nil, fmt.Errorf("invalid endpoint_v6 value %q", c.EndpointV6) + } + return &net.UDPAddr{IP: ip, Port: port}, nil + } + ip := net.ParseIP(c.EndpointV4) + if ip == nil { + return nil, fmt.Errorf("invalid endpoint_v4 value %q", c.EndpointV4) + } + return &net.UDPAddr{IP: ip, Port: port}, nil +} diff --git a/protocol/masque/outbound.go b/protocol/masque/outbound.go new file mode 100644 index 00000000..7d64f24d --- /dev/null +++ b/protocol/masque/outbound.go @@ -0,0 +1,300 @@ +package masque + +import ( + "context" + "encoding/base64" + "encoding/json" + "net" + "net/netip" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/cloudflare" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/masque" + "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" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.MASQUEOutboundOptions](registry, C.TypeMASQUE, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + ctx context.Context + dnsRouter adapter.DNSRouter + logger logger.ContextLogger + options option.MASQUEOutboundOptions + tunnel *masque.Tunnel + startHandler func() + + await chan struct{} +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.MASQUEOutboundOptions) (adapter.Outbound, error) { + outbound := &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeMASQUE, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, options.DialerOptions), + ctx: ctx, + dnsRouter: service.FromContext[adapter.DNSRouter](ctx), + logger: logger, + options: options, + await: make(chan struct{}), + } + outbound.startHandler = func() { + defer close(outbound.await) + cacheFile := service.FromContext[adapter.CacheFile](ctx) + var appConfig *Config + var err error + if !options.Profile.Recreate && cacheFile != nil && cacheFile.StoreMASQUEConfig() { + savedProfile := cacheFile.LoadMASQUEConfig(tag) + if savedProfile != nil { + if err = json.Unmarshal(savedProfile.Content, &appConfig); err != nil { + logger.ErrorContext(ctx, err) + return + } + } + } + if appConfig == nil { + appConfig, err = outbound.createConfig() + if err != nil { + logger.ErrorContext(ctx, err) + return + } + if cacheFile != nil && cacheFile.StoreMASQUEConfig() { + content, err := json.Marshal(appConfig) + if err != nil { + logger.ErrorContext(ctx, err) + return + } + cacheFile.SaveMASQUEConfig(tag, &adapter.SavedBinary{ + LastUpdated: time.Now(), + Content: content, + LastEtag: "", + }) + } + } + privKey, err := appConfig.GetEcPrivateKey() + if err != nil { + logger.ErrorContext(ctx, E.New("failed to get private key: ", err)) + return + } + peerPubKey, err := appConfig.GetEcEndpointPublicKey() + if err != nil { + logger.ErrorContext(ctx, E.New("failed to get public key: ", err)) + return + } + cert, err := masque.GenerateCert(privKey, &privKey.PublicKey) + if err != nil { + logger.ErrorContext(ctx, E.New("failed to generate cert: ", err)) + return + } + tlsConfig, err := tls.NewMASQUEClient(ctx, logger, "consumer-masque.cloudflareclient.com", cert, privKey, peerPubKey, options.MASQUEOutboundTLSOptions) + if err != nil { + logger.ErrorContext(ctx, E.New("failed to prepare TLS config: ", err)) + return + } + endpoint, err := appConfig.SelectEndpointFromConfig(options.UseHTTP2, options.UseIPv6, 443) + if err != nil { + logger.ErrorContext(ctx, E.New("failed to select endpoint: ", err)) + return + } + var udpTimeout time.Duration + if options.UDPTimeout != 0 { + udpTimeout = time.Duration(options.UDPTimeout) + } else { + udpTimeout = C.UDPTimeout + } + var udpKeepalivePeriod time.Duration + if options.UDPKeepalivePeriod != 0 { + udpKeepalivePeriod = time.Duration(options.UDPKeepalivePeriod) + } else { + udpKeepalivePeriod = time.Second * 30 + } + outboundDialer, err := dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: options.DialerOptions, + RemoteIsDomain: false, + ResolverOnDetour: true, + }) + if err != nil { + logger.ErrorContext(ctx, err) + return + } + tunnel, err := masque.NewTunnel( + ctx, + logger, + masque.TunnelOptions{ + Dialer: outboundDialer, + Address: []netip.Prefix{ + netip.MustParsePrefix(appConfig.IPv4 + "/32"), + netip.MustParsePrefix(appConfig.IPv6 + "/128"), + }, + Endpoint: endpoint, + TLSConfig: tlsConfig, + UseHTTP2: options.UseHTTP2, + UDPTimeout: udpTimeout, + UDPKeepalivePeriod: udpKeepalivePeriod, + UDPInitialPacketSize: options.UDPInitialPacketSize, + ReconnectDelay: options.ReconnectDelay.Build(), + }) + if err != nil { + logger.ErrorContext(ctx, err) + return + } + outbound.tunnel = tunnel + if err = outbound.tunnel.Start(false); err != nil { + logger.ErrorContext(ctx, err) + return + } + if err = outbound.tunnel.Start(true); err != nil { + logger.ErrorContext(ctx, err) + return + } + } + return outbound, nil +} + +func (w *Outbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStatePostStart { + return nil + } + go w.startHandler() + return nil +} + +func (w *Outbound) Close() error { + if err := w.isTunnelInitialized(w.ctx); err != nil { + return err + } + return w.tunnel.Close() +} + +func (w *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if err := w.isTunnelInitialized(ctx); err != nil { + return nil, err + } + switch network { + case N.NetworkTCP: + w.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + w.logger.InfoContext(ctx, "outbound packet connection to ", destination) + } + if destination.IsDomain() { + destinationAddresses, err := w.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, err + } + return N.DialSerial(ctx, w.tunnel, network, destination, destinationAddresses) + } else if !destination.Addr.IsValid() { + return nil, E.New("invalid destination: ", destination) + } + return w.tunnel.DialContext(ctx, network, destination) +} + +func (w *Outbound) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) { + if err := w.isTunnelInitialized(ctx); err != nil { + return nil, netip.Addr{}, err + } + w.logger.InfoContext(ctx, "outbound packet connection to ", destination) + if destination.IsDomain() { + destinationAddresses, err := w.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, netip.Addr{}, err + } + return N.ListenSerial(ctx, w.tunnel, destination, destinationAddresses) + } + packetConn, err := w.tunnel.ListenPacket(ctx, destination) + if err != nil { + return nil, netip.Addr{}, err + } + if destination.IsIP() { + return packetConn, destination.Addr, nil + } + return packetConn, netip.Addr{}, nil +} + +func (w *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + packetConn, destinationAddress, err := w.ListenPacketWithDestination(ctx, destination) + if err != nil { + return nil, err + } + if destinationAddress.IsValid() && destination != M.SocksaddrFrom(destinationAddress, destination.Port) { + return bufio.NewNATPacketConn(bufio.NewPacketConn(packetConn), M.SocksaddrFrom(destinationAddress, destination.Port), destination), nil + } + return packetConn, nil +} + +func (w *Outbound) isTunnelInitialized(ctx context.Context) error { + select { + case <-w.await: + case <-ctx.Done(): + return ctx.Err() + } + if w.tunnel == nil { + return E.New("tunnel not initialized") + } + return nil +} + +func (w *Outbound) createConfig() (*Config, error) { + opts := make([]cloudflare.CloudflareApiOption, 0, 1) + if w.options.Profile.Detour != "" { + detour, ok := service.FromContext[adapter.OutboundManager](w.ctx).Outbound(w.options.Profile.Detour) + if !ok { + return nil, E.New("outbound detour not found: ", w.options.Profile.Detour) + } + opts = append(opts, cloudflare.WithDialContext(func(ctx context.Context, network, addr string) (net.Conn, error) { + return detour.DialContext(ctx, network, M.ParseSocksaddr(addr)) + })) + } + api := cloudflare.NewCloudflareApi(opts...) + var profile *cloudflare.CloudflareProfile + var err error + if w.options.Profile.AuthToken != "" && w.options.Profile.ID != "" { + profile, err = api.GetProfile(w.ctx, w.options.Profile.AuthToken, w.options.Profile.ID) + if err != nil { + return nil, err + } + } else { + wgPrivateKey, err := wgtypes.GeneratePrivateKey() + if err != nil { + return nil, err + } + profile, err = api.CreateProfile(w.ctx, wgPrivateKey.PublicKey().String()) + if err != nil { + return nil, err + } + } + privateKey, publicKey, err := masque.GenerateEcKeyPair() + if err != nil { + return nil, E.New("failed to generate key pair: ", err) + } + updatedProfile, err := api.EnrollKey(w.ctx, profile.Token, profile.ID, cloudflare.KeyTypeMasque, cloudflare.TunTypeMasque, base64.StdEncoding.EncodeToString(publicKey)) + if err != nil { + return nil, err + } + return &Config{ + PrivateKey: base64.StdEncoding.EncodeToString(privateKey), + EndpointV4: updatedProfile.Config.Peers[0].Endpoint.V4[:len(updatedProfile.Config.Peers[0].Endpoint.V4)-2], + EndpointV6: updatedProfile.Config.Peers[0].Endpoint.V6[1 : len(updatedProfile.Config.Peers[0].Endpoint.V6)-3], + EndpointH2V4: cloudflare.DefaultEndpointH2V4, + EndpointH2V6: cloudflare.DefaultEndpointH2V6, + EndpointPubKey: updatedProfile.Config.Peers[0].PublicKey, + License: updatedProfile.Account.License, + ID: updatedProfile.ID, + AccessToken: profile.Token, + IPv4: updatedProfile.Config.Interface.Addresses.V4, + IPv6: updatedProfile.Config.Interface.Addresses.V6, + }, nil +} diff --git a/protocol/mtproxy/dialer.go b/protocol/mtproxy/dialer.go new file mode 100644 index 00000000..aba39f63 --- /dev/null +++ b/protocol/mtproxy/dialer.go @@ -0,0 +1,38 @@ +package mtproxy + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/adapter" + M "github.com/sagernet/sing/common/metadata" +) + +type Dialer struct { + handler adapter.ConnectionHandlerFuncEx +} + +func NewDialer(handler adapter.ConnectionHandlerFuncEx) *Dialer { + return &Dialer{handler} +} + +func (d *Dialer) Dial(network, address string) (net.Conn, error) { + return d.DialContext(context.Background(), network, address) +} + +func (d *Dialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + inConn, outConn := net.Pipe() + var metadata adapter.InboundContext + if streamContext, ok := ctx.(streamContext); ok { + metadata.Source = M.SocksaddrFromNet(streamContext.ClientAddr()) + metadata.User = streamContext.SecretName() + } + metadata.Destination = M.ParseSocksaddr(address) + d.handler(ctx, inConn, metadata, func(error) {}) + return outConn, nil +} + +type streamContext interface { + ClientAddr() net.Addr + SecretName() string +} diff --git a/protocol/mtproxy/inbound.go b/protocol/mtproxy/inbound.go new file mode 100644 index 00000000..48f829a5 --- /dev/null +++ b/protocol/mtproxy/inbound.go @@ -0,0 +1,132 @@ +package mtproxy + +import ( + "context" + "net" + + "github.com/dolonet/mtg-multi/antireplay" + "github.com/dolonet/mtg-multi/events" + "github.com/dolonet/mtg-multi/ipblocklist" + "github.com/dolonet/mtg-multi/mtglib" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.MTProxyInboundOptions](registry, C.TypeMTProxy, NewInbound) +} + +type Inbound struct { + inbound.Adapter + ctx context.Context + router adapter.ConnectionRouterEx + logger logger.ContextLogger + listener *listener.Listener + proxy *mtglib.Proxy +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.MTProxyInboundOptions) (adapter.Inbound, error) { + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeMTProxy, tag), + ctx: ctx, + router: router, + logger: logger, + listener: listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Listen: options.ListenOptions, + }), + } + mtgLogger := NewLoggerAdapter(logger) + secrets := make(map[string]mtglib.Secret, len(options.Users)) + for _, user := range options.Users { + secret := mtglib.Secret{} + err := secret.Set(user.Secret) + if err != nil { + return nil, err + } + secrets[user.Name] = secret + } + opts := mtglib.ProxyOpts{ + Logger: mtgLogger, + Network: NewNetworkAdapter(ctx, NewDialer(inbound.newConnection)), + AntiReplayCache: antireplay.NewNoop(), + IPBlocklist: ipblocklist.NewNoop(), + IPAllowlist: ipblocklist.NewNoop(), + EventStream: events.NewNoopStream(), + + Secrets: secrets, + Concurrency: options.GetConcurrency(), + DomainFrontingPort: options.GetDomainFrontingPort(), + DomainFrontingIP: options.DomainFrontingIP, + DomainFrontingProxyProtocol: options.DomainFrontingProxyProtocol, + PreferIP: options.GetPreferIP(), + AutoUpdate: options.AutoUpdate, + + AllowFallbackOnUnknownDC: options.AllowFallbackOnUnknownDC, + TolerateTimeSkewness: options.TolerateTimeSkewness.Build(), + IdleTimeout: options.GetIdleTimeout(), + HandshakeTimeout: options.GetHandshakeTimeout(), + + DoppelGangerURLs: options.DoppelGangerURLs, + DoppelGangerPerRaid: options.GetDoppelGangerPerRaid(), + DoppelGangerEach: options.GetDoppelGangerEach(), + DoppelGangerDRS: options.DoppelGangerDRS, + + ThrottleMaxConnections: options.ThrottleMaxConnections, + ThrottleCheckInterval: options.GetThrottleCheckInterval(), + } + proxy, err := mtglib.NewProxy(opts) + if err != nil { + return nil, E.New("cannot create a proxy: ", err) + } + inbound.proxy = proxy + return inbound, nil +} + +func (n *Inbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + listener, err := n.listener.ListenTCP() + if err != nil { + return err + } + go n.proxy.Serve(listener) + return nil +} + +func (n *Inbound) Close() error { + n.proxy.Shutdown() + return common.Close( + &n.listener, + ) +} + +func (h *Inbound) UpdateUsers(users []option.MTProxyUser) { + secrets := make(map[string]mtglib.Secret, len(users)) + for _, user := range users { + secret := mtglib.Secret{} + err := secret.Set(user.Secret) + if err != nil { + return + } + secrets[user.Name] = secret + } + h.proxy.UpdateUsers(secrets) +} + +func (h *Inbound) newConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + h.logger.InfoContext(ctx, "[", metadata.User, "] inbound connection to ", metadata.Destination) + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} diff --git a/protocol/mtproxy/logger.go b/protocol/mtproxy/logger.go new file mode 100644 index 00000000..351fc6a5 --- /dev/null +++ b/protocol/mtproxy/logger.go @@ -0,0 +1,60 @@ +package mtproxy + +import ( + "fmt" + + "github.com/dolonet/mtg-multi/mtglib" + "github.com/sagernet/sing/common/logger" +) + +type LoggerAdapter struct { + logger logger.Logger +} + +func NewLoggerAdapter(logger logger.Logger) *LoggerAdapter { + return &LoggerAdapter{logger} +} + +func (l *LoggerAdapter) Named(name string) mtglib.Logger { + return l +} + +func (l *LoggerAdapter) BindInt(name string, value int) mtglib.Logger { + return l +} + +func (l *LoggerAdapter) BindStr(name, value string) mtglib.Logger { + return l +} + +func (l *LoggerAdapter) BindJSON(name, value string) mtglib.Logger { + return l +} + +func (l *LoggerAdapter) Printf(format string, args ...any) { + l.logger.Info(fmt.Sprintf(format, args...)) +} + +func (l *LoggerAdapter) Info(msg string) { + l.logger.Info(msg) +} + +func (l *LoggerAdapter) InfoError(msg string, err error) { + l.logger.Error(msg, err) +} + +func (l *LoggerAdapter) Warning(msg string) { + l.logger.Warn(msg) +} + +func (l *LoggerAdapter) WarningError(msg string, err error) { + l.logger.Warn(msg, err) +} + +func (l *LoggerAdapter) Debug(msg string) { + l.logger.Debug(msg) +} + +func (l *LoggerAdapter) DebugError(msg string, err error) { + l.logger.Debug(msg, err) +} diff --git a/protocol/mtproxy/network.go b/protocol/mtproxy/network.go new file mode 100644 index 00000000..a14f8f42 --- /dev/null +++ b/protocol/mtproxy/network.go @@ -0,0 +1,43 @@ +package mtproxy + +import ( + "context" + "net" + "net/http" + + "github.com/dolonet/mtg-multi/essentials" +) + +type NetworkAdapter struct { + ctx context.Context + dialer essentials.Dialer +} + +func NewNetworkAdapter(ctx context.Context, dialer essentials.Dialer) *NetworkAdapter { + return &NetworkAdapter{ctx, dialer} +} + +func (a *NetworkAdapter) Dial(network, address string) (essentials.Conn, error) { + return a.DialContext(a.ctx, network, address) +} + +func (a *NetworkAdapter) DialContext(ctx context.Context, network, address string) (essentials.Conn, error) { + conn, err := a.dialer.DialContext(ctx, network, address) + if err != nil { + return nil, err + } + return essentials.WrapNetConn(conn), nil +} + +func (a *NetworkAdapter) MakeHTTPClient(func(ctx context.Context, network, address string) (essentials.Conn, error)) *http.Client { + return &http.Client{ + Timeout: 10, + Transport: &http.Transport{DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return a.DialContext(ctx, network, addr) + }}, + } +} + +func (a *NetworkAdapter) NativeDialer() essentials.Dialer { + return a.dialer +} diff --git a/protocol/parser/outbound.go b/protocol/parser/outbound.go new file mode 100644 index 00000000..2f16f277 --- /dev/null +++ b/protocol/parser/outbound.go @@ -0,0 +1,38 @@ +package parser + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/parser/link" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/service" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.ParserOutboundOptions](registry, C.TypeParser, NewOutbound) +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ParserOutboundOptions) (adapter.Outbound, error) { + if options.Link == "" { + return nil, E.New("missing link") + } + outboundOptions, err := link.ParseSubscriptionLink(options.Link) + if err != nil { + return nil, err + } + if dialerOptions, ok := outboundOptions.Options.(option.DialerOptionsWrapper); ok { + dialerOptions.ReplaceDialerOptions(options.DialerOptions) + } + outboundRegistry := service.FromContext[adapter.OutboundRegistry](ctx) + outbound, err := outboundRegistry.UnsafeCreateOutbound(ctx, router, logger, tag, outboundOptions.Type, outboundOptions.Options) + if err != nil { + return nil, err + } + return outbound, nil +} diff --git a/protocol/relay/outbound.go b/protocol/relay/outbound.go new file mode 100644 index 00000000..e69de29b diff --git a/protocol/trojan/inbound.go b/protocol/trojan/inbound.go index 6e11c088..e089018c 100644 --- a/protocol/trojan/inbound.go +++ b/protocol/trojan/inbound.go @@ -164,6 +164,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 600c7f93..e4ff726b 100644 --- a/protocol/tuic/inbound.go +++ b/protocol/tuic/inbound.go @@ -168,3 +168,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/server.go b/protocol/tunnel/server.go deleted file mode 100644 index d447be8c..00000000 --- a/protocol/tunnel/server.go +++ /dev/null @@ -1,203 +0,0 @@ -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" - 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/service" -) - -func RegisterServerEndpoint(registry *endpoint.Registry) { - endpoint.Register[option.TunnelServerEndpointOptions](registry, C.TypeTunnelServer, NewServerEndpoint) -} - -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 -} - -func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TunnelServerEndpointOptions) (adapter.Endpoint, error) { - serverUUID, err := uuid.FromString(options.UUID) - if err != nil { - return nil, err - } - server := &ServerEndpoint{ - Adapter: outbound.NewAdapter(C.TypeTunnelServer, tag, []string{N.NetworkTCP}, []string{}), - logger: logger, - router: router, - uuid: serverUUID, - } - inboundRegistry := service.FromContext[adapter.InboundRegistry](ctx) - inbound, err := inboundRegistry.Create(ctx, NewRouter(router, logger, server.connHandler), logger, options.Inbound.Tag, options.Inbound.Type, options.Inbound.Options) - if err != nil { - return nil, err - } - server.inbound = inbound - server.users = make(map[uuid.UUID]uuid.UUID, len(options.Users)) - server.keys = make(map[uuid.UUID]uuid.UUID, len(options.Users)) - server.conns = make(map[uuid.UUID]chan net.Conn) - for _, user := range options.Users { - key, err := uuid.FromString(user.Key) - if err != nil { - return nil, err - } - uuid, err := uuid.FromString(user.UUID) - if err != nil { - return nil, err - } - server.users[key] = uuid - server.keys[uuid] = key - server.conns[uuid] = make(chan net.Conn, 10) - } - if options.ConnectTimeout != 0 { - server.timeout = time.Duration(options.ConnectTimeout) - } else { - server.timeout = C.TCPConnectTimeout - } - return server, nil -} - -func (s *ServerEndpoint) Start(stage adapter.StartStage) error { - return s.inbound.Start(stage) -} - -func (s *ServerEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { - if network != N.NetworkTCP { - return nil, os.ErrInvalid - } - var sourceUUID *uuid.UUID - var ch chan net.Conn - if metadata := adapter.ContextFrom(ctx); metadata != nil { - if metadata.TunnelDestination != "" { - tunnelDestination, err := uuid.FromString(metadata.TunnelDestination) - 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) - if err != nil { - return nil, err - } - sourceUUID = &tunnelSource - } - } - if ch == nil { - return nil, E.New("tunnel destination not set") - } - if sourceUUID == nil { - sourceUUID = &s.uuid - } - ctx, cancel := context.WithTimeout(ctx, s.timeout) - defer cancel() - for { - select { - case <-ctx.Done(): - return nil, ctx.Err() - default: - } - select { - case conn := <-ch: - err := WriteRequest(conn, &Request{UUID: *sourceUUID, Command: CommandTCP, Destination: destination}) - if err != nil { - s.logger.ErrorContext(ctx, err) - continue - } - return conn, nil - case <-ctx.Done(): - return nil, ctx.Err() - } - } -} - -func (s *ServerEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { - return nil, os.ErrInvalid -} - -func (s *ServerEndpoint) Close() error { - return common.Close(s.inbound) -} - -func (s *ServerEndpoint) connHandler(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) error { - if metadata.Destination != Destination { - s.router.RouteConnectionEx(ctx, conn, metadata, onClose) - return nil - } - request, err := ReadRequest(conn) - if err != nil { - 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") - } - ch := s.conns[uuid] - select { - case ch <- conn: - default: - oldConn := <-ch - oldConn.Close() - ch <- conn - } - return nil - } - if request.Command == CommandTCP { - sourceUUID, ok := s.users[request.UUID] - if !ok { - return E.New("key ", request.UUID, " not found") - } - 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") - } - } - s.mtx.Unlock() - metadata.Inbound = s.Tag() - metadata.InboundType = C.TypeTunnelServer - metadata.Destination = request.Destination - metadata.TunnelSource = sourceUUID.String() - metadata.TunnelDestination = request.DestinationUUID.String() - s.router.RouteConnectionEx(ctx, conn, metadata, onClose) - return nil - } - return E.New("command ", request.Command, " not found") -} diff --git a/protocol/vless/encryption/client.go b/protocol/vless/encryption/client.go new file mode 100644 index 00000000..947b279e --- /dev/null +++ b/protocol/vless/encryption/client.go @@ -0,0 +1,214 @@ +package encryption + +import ( + "crypto/cipher" + "crypto/ecdh" + "crypto/mlkem" + "crypto/rand" + "io" + "net" + "sync" + "time" + + "github.com/sagernet/sing-box/common/xray/cpuid" + E "github.com/sagernet/sing/common/exceptions" + "lukechampine.com/blake3" +) + +type ClientInstance struct { + NfsPKeys []any + NfsPKeysBytes [][]byte + Hash32s [][32]byte + RelaysLength int + XorMode uint32 + Seconds uint32 + PaddingLens [][3]int + PaddingGaps [][3]int + + RWLock sync.RWMutex + Expire time.Time + PfsKey []byte + Ticket []byte +} + +func (i *ClientInstance) Init(nfsPKeysBytes [][]byte, xorMode, seconds uint32, padding string) (err error) { + if i.NfsPKeys != nil { + return E.New("already initialized") + } + l := len(nfsPKeysBytes) + if l == 0 { + return E.New("empty nfsPKeysBytes") + } + i.NfsPKeys = make([]any, l) + i.NfsPKeysBytes = nfsPKeysBytes + i.Hash32s = make([][32]byte, l) + for j, k := range nfsPKeysBytes { + if len(k) == 32 { + if i.NfsPKeys[j], err = ecdh.X25519().NewPublicKey(k); err != nil { + return + } + i.RelaysLength += 32 + 32 + } else { + if i.NfsPKeys[j], err = mlkem.NewEncapsulationKey768(k); err != nil { + return + } + i.RelaysLength += 1088 + 32 + } + i.Hash32s[j] = blake3.Sum256(k) + } + i.RelaysLength -= 32 + i.XorMode = xorMode + i.Seconds = seconds + return ParsePadding(padding, &i.PaddingLens, &i.PaddingGaps) +} + +func (i *ClientInstance) IsFullRandomXorMode() bool { + return i.XorMode == 2 +} + +func (i *ClientInstance) Handshake(conn net.Conn) (*CommonConn, error) { + if i.NfsPKeys == nil { + return nil, E.New("uninitialized") + } + c := NewCommonConn(conn, cpuid.HasAESGCM) + + ivAndRealysLength := 16 + i.RelaysLength + pfsKeyExchangeLength := 18 + 1184 + 32 + 16 + paddingLength, paddingLens, paddingGaps := CreatePadding(i.PaddingLens, i.PaddingGaps) + clientHello := make([]byte, ivAndRealysLength+pfsKeyExchangeLength+paddingLength) + + iv := clientHello[:16] + rand.Read(iv) + relays := clientHello[16:ivAndRealysLength] + var nfsKey []byte + var lastCTR cipher.Stream + for j, k := range i.NfsPKeys { + var index = 32 + if k, ok := k.(*ecdh.PublicKey); ok { + privateKey, _ := ecdh.X25519().GenerateKey(rand.Reader) + copy(relays, privateKey.PublicKey().Bytes()) + var err error + nfsKey, err = privateKey.ECDH(k) + if err != nil { + return nil, err + } + } + if k, ok := k.(*mlkem.EncapsulationKey768); ok { + var ciphertext []byte + nfsKey, ciphertext = k.Encapsulate() + copy(relays, ciphertext) + index = 1088 + } + if i.XorMode > 0 { // this xor can (others can't) be recovered by client's config, revealing an X25519 public key / ML-KEM-768 ciphertext, that's why "native" values + NewCTR(i.NfsPKeysBytes[j], iv).XORKeyStream(relays, relays[:index]) // make X25519 public key / ML-KEM-768 ciphertext distinguishable from random bytes + } + if lastCTR != nil { + lastCTR.XORKeyStream(relays, relays[:32]) // make this relay irreplaceable + } + if j == len(i.NfsPKeys)-1 { + break + } + lastCTR = NewCTR(nfsKey, iv) + lastCTR.XORKeyStream(relays[index:], i.Hash32s[j+1][:]) + relays = relays[index+32:] + } + nfsAEAD := NewAEAD(iv, nfsKey, c.UseAES) + + if i.Seconds > 0 { + i.RWLock.RLock() + if time.Now().Before(i.Expire) { + c.Client = i + c.UnitedKey = append(i.PfsKey, nfsKey...) // different unitedKey for each connection + nfsAEAD.Seal(clientHello[:ivAndRealysLength], nil, EncodeLength(32), nil) + nfsAEAD.Seal(clientHello[:ivAndRealysLength+18], nil, i.Ticket, nil) + i.RWLock.RUnlock() + c.PreWrite = clientHello[:ivAndRealysLength+18+32] + c.AEAD = NewAEAD(clientHello[ivAndRealysLength+18:ivAndRealysLength+18+32], c.UnitedKey, c.UseAES) + if i.XorMode == 2 { + c.Conn = NewXorConn(conn, NewCTR(c.UnitedKey, iv), nil, len(c.PreWrite), 16) + } + return c, nil + } + i.RWLock.RUnlock() + } + + pfsKeyExchange := clientHello[ivAndRealysLength : ivAndRealysLength+pfsKeyExchangeLength] + nfsAEAD.Seal(pfsKeyExchange[:0], nil, EncodeLength(pfsKeyExchangeLength-18), nil) + mlkem768DKey, _ := mlkem.GenerateKey768() + x25519SKey, _ := ecdh.X25519().GenerateKey(rand.Reader) + pfsPublicKey := append(mlkem768DKey.EncapsulationKey().Bytes(), x25519SKey.PublicKey().Bytes()...) + nfsAEAD.Seal(pfsKeyExchange[:18], nil, pfsPublicKey, nil) + + padding := clientHello[ivAndRealysLength+pfsKeyExchangeLength:] + nfsAEAD.Seal(padding[:0], nil, EncodeLength(paddingLength-18), nil) + nfsAEAD.Seal(padding[:18], nil, padding[18:paddingLength-16], nil) + + paddingLens[0] = ivAndRealysLength + pfsKeyExchangeLength + paddingLens[0] + for i, l := range paddingLens { // sends padding in a fragmented way, to create variable traffic pattern, before inner VLESS flow takes control + if l > 0 { + if _, err := conn.Write(clientHello[:l]); err != nil { + return nil, err + } + clientHello = clientHello[l:] + } + if len(paddingGaps) > i { + time.Sleep(paddingGaps[i]) + } + } + + encryptedPfsPublicKey := make([]byte, 1088+32+16) + if _, err := io.ReadFull(conn, encryptedPfsPublicKey); err != nil { + return nil, err + } + nfsAEAD.Open(encryptedPfsPublicKey[:0], MaxNonce, encryptedPfsPublicKey, nil) + mlkem768Key, err := mlkem768DKey.Decapsulate(encryptedPfsPublicKey[:1088]) + if err != nil { + return nil, err + } + peerX25519PKey, err := ecdh.X25519().NewPublicKey(encryptedPfsPublicKey[1088 : 1088+32]) + if err != nil { + return nil, err + } + x25519Key, err := x25519SKey.ECDH(peerX25519PKey) + if err != nil { + return nil, err + } + pfsKey := make([]byte, 32+32) // no more capacity + copy(pfsKey, mlkem768Key) + copy(pfsKey[32:], x25519Key) + c.UnitedKey = append(pfsKey, nfsKey...) + c.AEAD = NewAEAD(pfsPublicKey, c.UnitedKey, c.UseAES) + c.PeerAEAD = NewAEAD(encryptedPfsPublicKey[:1088+32], c.UnitedKey, c.UseAES) + + encryptedTicket := make([]byte, 32) + if _, err := io.ReadFull(conn, encryptedTicket); err != nil { + return nil, err + } + if _, err := c.PeerAEAD.Open(encryptedTicket[:0], nil, encryptedTicket, nil); err != nil { + return nil, err + } + seconds := DecodeLength(encryptedTicket) + + if i.Seconds > 0 && seconds > 0 { + i.RWLock.Lock() + i.Expire = time.Now().Add(time.Duration(seconds) * time.Second) + i.PfsKey = pfsKey + i.Ticket = encryptedTicket[:16] + i.RWLock.Unlock() + } + + encryptedLength := make([]byte, 18) + if _, err := io.ReadFull(conn, encryptedLength); err != nil { + return nil, err + } + if _, err := c.PeerAEAD.Open(encryptedLength[:0], nil, encryptedLength, nil); err != nil { + return nil, err + } + length := DecodeLength(encryptedLength[:2]) + c.PeerPadding = make([]byte, length) // important: allows server sends padding slowly, eliminating 1-RTT's traffic pattern + + if i.XorMode == 2 { + c.Conn = NewXorConn(conn, NewCTR(c.UnitedKey, iv), NewCTR(c.UnitedKey, encryptedTicket[:16]), 0, length) + } + return c, nil +} diff --git a/protocol/vless/encryption/common.go b/protocol/vless/encryption/common.go new file mode 100644 index 00000000..84e3818d --- /dev/null +++ b/protocol/vless/encryption/common.go @@ -0,0 +1,297 @@ +package encryption + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + "time" + + "github.com/sagernet/sing-box/common/xray/crypto" + E "github.com/sagernet/sing/common/exceptions" + "golang.org/x/crypto/chacha20poly1305" + "lukechampine.com/blake3" +) + +var OutBytesPool = sync.Pool{ + New: func() any { + return make([]byte, 5+8192+16) + }, +} + +type EncryptionConn interface { + net.Conn + IsEncryptionLayer() bool +} + +type CommonConn struct { + net.Conn + UseAES bool + Client *ClientInstance + UnitedKey []byte + PreWrite []byte + AEAD *AEAD + PeerAEAD *AEAD + PeerPadding []byte + rawInput bytes.Buffer + input bytes.Reader +} + +func NewCommonConn(conn net.Conn, useAES bool) *CommonConn { + return &CommonConn{ + Conn: conn, + UseAES: useAES, + } +} + +func (c *CommonConn) Write(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + outBytes := OutBytesPool.Get().([]byte) + defer OutBytesPool.Put(outBytes) + for n := 0; n < len(b); { + b := b[n:] + if len(b) > 8192 { + b = b[:8192] // for avoiding another copy() in peer's Read() + } + n += len(b) + headerAndData := outBytes[:5+len(b)+16] + EncodeHeader(headerAndData, len(b)+16) + max := false + if bytes.Equal(c.AEAD.Nonce[:], MaxNonce) { + max = true + } + c.AEAD.Seal(headerAndData[:5], nil, b, headerAndData[:5]) + if max { + c.AEAD = NewAEAD(headerAndData, c.UnitedKey, c.UseAES) + } + if c.PreWrite != nil { + headerAndData = append(c.PreWrite, headerAndData...) + c.PreWrite = nil + } + if _, err := c.Conn.Write(headerAndData); err != nil { + return 0, err + } + } + return len(b), nil +} + +func (c *CommonConn) Read(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + if c.PeerAEAD == nil { // client's 0-RTT + serverRandom := make([]byte, 16) + if _, err := io.ReadFull(c.Conn, serverRandom); err != nil { + return 0, err + } + c.PeerAEAD = NewAEAD(serverRandom, c.UnitedKey, c.UseAES) + if xorConn, ok := c.Conn.(*XorConn); ok { + xorConn.PeerCTR = NewCTR(c.UnitedKey, serverRandom) + } + } + if c.PeerPadding != nil { // client's 1-RTT + if _, err := io.ReadFull(c.Conn, c.PeerPadding); err != nil { + return 0, err + } + if _, err := c.PeerAEAD.Open(c.PeerPadding[:0], nil, c.PeerPadding, nil); err != nil { + return 0, err + } + c.PeerPadding = nil + } + if c.input.Len() > 0 { + return c.input.Read(b) + } + peerHeader := [5]byte{} + if _, err := io.ReadFull(c.Conn, peerHeader[:]); err != nil { + return 0, err + } + l, err := DecodeHeader(peerHeader[:]) // l: 17~17000 + if err != nil { + if c.Client != nil && errors.Is(err, ErrInvalidHeader) { // client's 0-RTT + c.Client.RWLock.Lock() + if bytes.HasPrefix(c.UnitedKey, c.Client.PfsKey) { + c.Client.Expire = time.Now() // expired + } + c.Client.RWLock.Unlock() + return 0, E.New("new handshake needed") + } + return 0, err + } + c.Client = nil + if c.rawInput.Cap() < l { + c.rawInput.Grow(l) // no need to use sync.Pool, because we are always reading + } + peerData := c.rawInput.Bytes()[:l] + if _, err := io.ReadFull(c.Conn, peerData); err != nil { + return 0, err + } + dst := peerData[:l-16] + if len(dst) <= len(b) { + dst = b[:len(dst)] // avoids another copy() + } + var newAEAD *AEAD + if bytes.Equal(c.PeerAEAD.Nonce[:], MaxNonce) { + newAEAD = NewAEAD(append(peerHeader[:], peerData...), c.UnitedKey, c.UseAES) + } + _, err = c.PeerAEAD.Open(dst[:0], nil, peerData, peerHeader[:]) + if newAEAD != nil { + c.PeerAEAD = newAEAD + } + if err != nil { + return 0, err + } + if len(dst) > len(b) { + c.input.Reset(dst[copy(b, dst):]) + dst = b // for len(dst) + } + return len(dst), nil +} + +// Upstream returns the underlying connection, allowing Vision to unwrap and access the TLS connection +func (c *CommonConn) Upstream() any { + return c.Conn +} + +func (c *CommonConn) IsEncryptionLayer() bool { + return true +} + +type AEAD struct { + cipher.AEAD + Nonce [12]byte +} + +func NewAEAD(ctx, key []byte, useAES bool) *AEAD { + k := make([]byte, 32) + blake3.DeriveKey(k, string(ctx), key) + var aead cipher.AEAD + if useAES { + block, _ := aes.NewCipher(k) + aead, _ = cipher.NewGCM(block) + } else { + aead, _ = chacha20poly1305.New(k) + } + return &AEAD{AEAD: aead} +} + +func (a *AEAD) Seal(dst, nonce, plaintext, additionalData []byte) []byte { + if nonce == nil { + nonce = IncreaseNonce(a.Nonce[:]) + } + return a.AEAD.Seal(dst, nonce, plaintext, additionalData) +} + +func (a *AEAD) Open(dst, nonce, ciphertext, additionalData []byte) ([]byte, error) { + if nonce == nil { + nonce = IncreaseNonce(a.Nonce[:]) + } + return a.AEAD.Open(dst, nonce, ciphertext, additionalData) +} + +func IncreaseNonce(nonce []byte) []byte { + for i := range 12 { + nonce[11-i]++ + if nonce[11-i] != 0 { + break + } + } + return nonce +} + +var MaxNonce = bytes.Repeat([]byte{255}, 12) + +func EncodeLength(l int) []byte { + return []byte{byte(l >> 8), byte(l)} +} + +func DecodeLength(b []byte) int { + return int(b[0])<<8 | int(b[1]) +} + +func EncodeHeader(h []byte, l int) { + h[0] = 23 + h[1] = 3 + h[2] = 3 + h[3] = byte(l >> 8) + h[4] = byte(l) +} + +var ErrInvalidHeader = errors.New("invalid header") + +func DecodeHeader(h []byte) (l int, err error) { + l = int(h[3])<<8 | int(h[4]) + if h[0] != 23 || h[1] != 3 || h[2] != 3 { + l = 0 + } + if l < 17 || l > 17000 { // TODO: TLSv1.3 max length + err = fmt.Errorf("%w: %v", ErrInvalidHeader, h[:5]) // DO NOT CHANGE: relied by client's Read() + } + return +} + +func ParsePadding(padding string, paddingLens, paddingGaps *[][3]int) (err error) { + if padding == "" { + return + } + maxLen := 0 + for i, s := range strings.Split(padding, ".") { + x := strings.Split(s, "-") + if len(x) < 3 || x[0] == "" || x[1] == "" || x[2] == "" { + return E.New("invalid padding lenth/gap parameter: " + s) + } + y := [3]int{} + if y[0], err = strconv.Atoi(x[0]); err != nil { + return + } + if y[1], err = strconv.Atoi(x[1]); err != nil { + return + } + if y[2], err = strconv.Atoi(x[2]); err != nil { + return + } + if i == 0 && (y[0] < 100 || y[1] < 18+17 || y[2] < 18+17) { + return E.New("first padding length must not be smaller than 35") + } + if i%2 == 0 { + *paddingLens = append(*paddingLens, y) + maxLen += max(y[1], y[2]) + } else { + *paddingGaps = append(*paddingGaps, y) + } + } + if maxLen > 18+65535 { + return E.New("total padding length must not be larger than 65553") + } + return +} + +func CreatePadding(paddingLens, paddingGaps [][3]int) (length int, lens []int, gaps []time.Duration) { + if len(paddingLens) == 0 { + paddingLens = [][3]int{{100, 111, 1111}, {50, 0, 3333}} + paddingGaps = [][3]int{{75, 0, 111}} + } + for _, y := range paddingLens { + l := 0 + if y[0] >= int(crypto.RandBetween(0, 100)) { + l = int(crypto.RandBetween(int64(y[1]), int64(y[2]))) + } + lens = append(lens, l) + length += l + } + for _, y := range paddingGaps { + g := 0 + if y[0] >= int(crypto.RandBetween(0, 100)) { + g = int(crypto.RandBetween(int64(y[1]), int64(y[2]))) + } + gaps = append(gaps, time.Duration(g)*time.Millisecond) + } + return +} diff --git a/protocol/vless/encryption/server.go b/protocol/vless/encryption/server.go new file mode 100644 index 00000000..6e25f166 --- /dev/null +++ b/protocol/vless/encryption/server.go @@ -0,0 +1,336 @@ +package encryption + +import ( + "bytes" + "crypto/cipher" + "crypto/ecdh" + "crypto/mlkem" + "crypto/rand" + "fmt" + "io" + "net" + "sync" + "time" + + "github.com/sagernet/sing-box/common/xray/crypto" + E "github.com/sagernet/sing/common/exceptions" + "lukechampine.com/blake3" +) + +type ServerSession struct { + PfsKey []byte + NfsKeys sync.Map +} + +type ServerInstance struct { + NfsSKeys []any + NfsPKeysBytes [][]byte + Hash32s [][32]byte + RelaysLength int + XorMode uint32 + SecondsFrom int64 + SecondsTo int64 + PaddingLens [][3]int + PaddingGaps [][3]int + + RWLock sync.RWMutex + Closed bool + Lasts map[int64][16]byte + Tickets [][16]byte + Sessions map[[16]byte]*ServerSession +} + +func (i *ServerInstance) Init(nfsSKeysBytes [][]byte, xorMode uint32, secondsFrom, secondsTo int64, padding string) (err error) { + if i.NfsSKeys != nil { + return E.New("already initialized") + } + l := len(nfsSKeysBytes) + if l == 0 { + return E.New("empty nfsSKeysBytes") + } + i.NfsSKeys = make([]any, l) + i.NfsPKeysBytes = make([][]byte, l) + i.Hash32s = make([][32]byte, l) + for j, k := range nfsSKeysBytes { + if len(k) == 32 { + if i.NfsSKeys[j], err = ecdh.X25519().NewPrivateKey(k); err != nil { + return + } + i.NfsPKeysBytes[j] = i.NfsSKeys[j].(*ecdh.PrivateKey).PublicKey().Bytes() + i.RelaysLength += 32 + 32 + } else { + if i.NfsSKeys[j], err = mlkem.NewDecapsulationKey768(k); err != nil { + return + } + i.NfsPKeysBytes[j] = i.NfsSKeys[j].(*mlkem.DecapsulationKey768).EncapsulationKey().Bytes() + i.RelaysLength += 1088 + 32 + } + i.Hash32s[j] = blake3.Sum256(i.NfsPKeysBytes[j]) + } + i.RelaysLength -= 32 + i.XorMode = xorMode + i.SecondsFrom = secondsFrom + i.SecondsTo = secondsTo + err = ParsePadding(padding, &i.PaddingLens, &i.PaddingGaps) + if err != nil { + return + } + if i.SecondsFrom > 0 || i.SecondsTo > 0 { + i.Lasts = make(map[int64][16]byte) + i.Tickets = make([][16]byte, 0, 1024) + i.Sessions = make(map[[16]byte]*ServerSession) + go func() { + for { + time.Sleep(time.Minute) + i.RWLock.Lock() + if i.Closed { + i.RWLock.Unlock() + return + } + minute := time.Now().Unix() / 60 + last := i.Lasts[minute] + delete(i.Lasts, minute) + delete(i.Lasts, minute-1) // for insurance + if last != [16]byte{} { + for j, ticket := range i.Tickets { + delete(i.Sessions, ticket) + if ticket == last { + i.Tickets = i.Tickets[j+1:] + break + } + } + } + i.RWLock.Unlock() + } + }() + } + return +} + +func (i *ServerInstance) Close() (err error) { + i.RWLock.Lock() + i.Closed = true + i.RWLock.Unlock() + return +} + +func (i *ServerInstance) IsXorMode() bool { + return i.XorMode > 0 +} + +func (i *ServerInstance) IsFullRandomXorMode() bool { + return i.XorMode == 2 +} + +func (i *ServerInstance) Handshake(conn net.Conn, fallback *[]byte) (*CommonConn, error) { + if i.NfsSKeys == nil { + return nil, E.New("uninitialized") + } + c := NewCommonConn(conn, true) + + ivAndRelays := make([]byte, 16+i.RelaysLength) + if _, err := io.ReadFull(conn, ivAndRelays); err != nil { + return nil, err + } + if fallback != nil { + *fallback = append(*fallback, ivAndRelays...) + } + iv := ivAndRelays[:16] + relays := ivAndRelays[16:] + var nfsKey []byte + var lastCTR cipher.Stream + for j, k := range i.NfsSKeys { + if lastCTR != nil { + lastCTR.XORKeyStream(relays, relays[:32]) // recover this relay + } + var index = 32 + if _, ok := k.(*mlkem.DecapsulationKey768); ok { + index = 1088 + } + if i.XorMode > 0 { + NewCTR(i.NfsPKeysBytes[j], iv).XORKeyStream(relays, relays[:index]) // we don't use buggy elligator2, because we have PSK :) + } + if k, ok := k.(*ecdh.PrivateKey); ok { + publicKey, err := ecdh.X25519().NewPublicKey(relays[:index]) + if err != nil { + return nil, err + } + if publicKey.Bytes()[31] > 127 { // we just don't want the observer can change even one bit without breaking the connection, though it has nothing to do with security + return nil, E.New("the highest bit of the last byte of the peer-sent X25519 public key is not 0") + } + nfsKey, err = k.ECDH(publicKey) + if err != nil { + return nil, err + } + } + if k, ok := k.(*mlkem.DecapsulationKey768); ok { + var err error + nfsKey, err = k.Decapsulate(relays[:index]) + if err != nil { + return nil, err + } + } + if j == len(i.NfsSKeys)-1 { + break + } + relays = relays[index:] + lastCTR = NewCTR(nfsKey, iv) + lastCTR.XORKeyStream(relays, relays[:32]) + if !bytes.Equal(relays[:32], i.Hash32s[j+1][:]) { + return nil, E.New("unexpected hash32: " + fmt.Sprintf("%v", relays[:32])) + } + relays = relays[32:] + } + nfsAEAD := NewAEAD(iv, nfsKey, c.UseAES) + + encryptedLength := make([]byte, 18) + if _, err := io.ReadFull(conn, encryptedLength); err != nil { + return nil, err + } + if fallback != nil { + *fallback = append(*fallback, encryptedLength...) + } + decryptedLength := make([]byte, 2) + if _, err := nfsAEAD.Open(decryptedLength[:0], nil, encryptedLength, nil); err != nil { + c.UseAES = !c.UseAES + nfsAEAD = NewAEAD(iv, nfsKey, c.UseAES) + if _, err := nfsAEAD.Open(decryptedLength[:0], nil, encryptedLength, nil); err != nil { + return nil, err + } + } + if fallback != nil { + *fallback = nil + } + length := DecodeLength(decryptedLength) + + if length == 32 { + if i.SecondsFrom == 0 && i.SecondsTo == 0 { + return nil, E.New("0-RTT is not allowed") + } + encryptedTicket := make([]byte, 32) + if _, err := io.ReadFull(conn, encryptedTicket); err != nil { + return nil, err + } + ticket, err := nfsAEAD.Open(nil, nil, encryptedTicket, nil) + if err != nil { + return nil, err + } + i.RWLock.RLock() + s := i.Sessions[[16]byte(ticket)] + i.RWLock.RUnlock() + if s == nil { + noises := make([]byte, crypto.RandBetween(1279, 2279)) // matches 1-RTT's server hello length for "random", though it is not important, just for example + var err error + for err == nil { + rand.Read(noises) + _, err = DecodeHeader(noises) + } + conn.Write(noises) // make client do new handshake + return nil, E.New("expired ticket") + } + if _, loaded := s.NfsKeys.LoadOrStore([32]byte(nfsKey), true); loaded { // prevents bad client also + return nil, E.New("replay detected") + } + c.UnitedKey = append(s.PfsKey, nfsKey...) // the same nfsKey links the upload & download (prevents server -> client's another request) + c.PreWrite = make([]byte, 16) + rand.Read(c.PreWrite) // always trust yourself, not the client (also prevents being parsed as TLS thus causing false interruption for "native" and "xorpub") + c.AEAD = NewAEAD(c.PreWrite, c.UnitedKey, c.UseAES) + c.PeerAEAD = NewAEAD(encryptedTicket, c.UnitedKey, c.UseAES) // unchangeable ctx (prevents server -> server), and different ctx length for upload / download (prevents client -> client) + if i.XorMode == 2 { + c.Conn = NewXorConn(conn, NewCTR(c.UnitedKey, c.PreWrite), NewCTR(c.UnitedKey, iv), 16, 0) // it doesn't matter if the attacker sends client's iv back to the client + } + return c, nil + } + + if length < 1184+32+16 { // client may send more public keys in the future's version + return nil, E.New("too short length") + } + encryptedPfsPublicKey := make([]byte, length) + if _, err := io.ReadFull(conn, encryptedPfsPublicKey); err != nil { + return nil, err + } + if _, err := nfsAEAD.Open(encryptedPfsPublicKey[:0], nil, encryptedPfsPublicKey, nil); err != nil { + return nil, err + } + mlkem768EKey, err := mlkem.NewEncapsulationKey768(encryptedPfsPublicKey[:1184]) + if err != nil { + return nil, err + } + mlkem768Key, encapsulatedPfsKey := mlkem768EKey.Encapsulate() + peerX25519PKey, err := ecdh.X25519().NewPublicKey(encryptedPfsPublicKey[1184 : 1184+32]) + if err != nil { + return nil, err + } + x25519SKey, _ := ecdh.X25519().GenerateKey(rand.Reader) + x25519Key, err := x25519SKey.ECDH(peerX25519PKey) + if err != nil { + return nil, err + } + pfsKey := make([]byte, 32+32) // no more capacity + copy(pfsKey, mlkem768Key) + copy(pfsKey[32:], x25519Key) + pfsPublicKey := append(encapsulatedPfsKey, x25519SKey.PublicKey().Bytes()...) + c.UnitedKey = append(pfsKey, nfsKey...) + c.AEAD = NewAEAD(pfsPublicKey, c.UnitedKey, c.UseAES) + c.PeerAEAD = NewAEAD(encryptedPfsPublicKey[:1184+32], c.UnitedKey, c.UseAES) + + ticket := [16]byte{} + rand.Read(ticket[:]) + var seconds int64 + if i.SecondsTo == 0 { + seconds = i.SecondsFrom * crypto.RandBetween(50, 100) / 100 + } else { + seconds = crypto.RandBetween(i.SecondsFrom, i.SecondsTo) + } + copy(ticket[:], EncodeLength(int(seconds))) + if seconds > 0 { + i.RWLock.Lock() + i.Lasts[(time.Now().Unix()+max(i.SecondsFrom, i.SecondsTo))/60+2] = ticket + i.Tickets = append(i.Tickets, ticket) + i.Sessions[ticket] = &ServerSession{PfsKey: pfsKey} + i.RWLock.Unlock() + } + + pfsKeyExchangeLength := 1088 + 32 + 16 + encryptedTicketLength := 32 + paddingLength, paddingLens, paddingGaps := CreatePadding(i.PaddingLens, i.PaddingGaps) + serverHello := make([]byte, pfsKeyExchangeLength+encryptedTicketLength+paddingLength) + nfsAEAD.Seal(serverHello[:0], MaxNonce, pfsPublicKey, nil) + c.AEAD.Seal(serverHello[:pfsKeyExchangeLength], nil, ticket[:], nil) + padding := serverHello[pfsKeyExchangeLength+encryptedTicketLength:] + c.AEAD.Seal(padding[:0], nil, EncodeLength(paddingLength-18), nil) + c.AEAD.Seal(padding[:18], nil, padding[18:paddingLength-16], nil) + + paddingLens[0] = pfsKeyExchangeLength + encryptedTicketLength + paddingLens[0] + for i, l := range paddingLens { // sends padding in a fragmented way, to create variable traffic pattern, before inner VLESS flow takes control + if l > 0 { + if _, err := conn.Write(serverHello[:l]); err != nil { + return nil, err + } + serverHello = serverHello[l:] + } + if len(paddingGaps) > i { + time.Sleep(paddingGaps[i]) + } + } + + // important: allows client sends padding slowly, eliminating 1-RTT's traffic pattern + if _, err := io.ReadFull(conn, encryptedLength); err != nil { + return nil, err + } + if _, err := nfsAEAD.Open(encryptedLength[:0], nil, encryptedLength, nil); err != nil { + return nil, err + } + encryptedPadding := make([]byte, DecodeLength(encryptedLength[:2])) + if _, err := io.ReadFull(conn, encryptedPadding); err != nil { + return nil, err + } + if _, err := nfsAEAD.Open(encryptedPadding[:0], nil, encryptedPadding, nil); err != nil { + return nil, err + } + + if i.XorMode == 2 { + c.Conn = NewXorConn(conn, NewCTR(c.UnitedKey, ticket[:]), NewCTR(c.UnitedKey, iv), 0, 0) + } + return c, nil +} diff --git a/protocol/vless/encryption/xor.go b/protocol/vless/encryption/xor.go new file mode 100644 index 00000000..ac7bd58d --- /dev/null +++ b/protocol/vless/encryption/xor.go @@ -0,0 +1,101 @@ +package encryption + +import ( + "crypto/aes" + "crypto/cipher" + "net" + + "lukechampine.com/blake3" +) + +func NewCTR(key, iv []byte) cipher.Stream { + k := make([]byte, 32) + blake3.DeriveKey(k, "VLESS", key) // avoids using key directly + block, _ := aes.NewCipher(k) + return cipher.NewCTR(block, iv) +} + +type XorConn struct { + net.Conn + CTR cipher.Stream + PeerCTR cipher.Stream + OutSkip int + OutHeader []byte + InSkip int + InHeader []byte +} + +func NewXorConn(conn net.Conn, ctr, peerCTR cipher.Stream, outSkip, inSkip int) *XorConn { + return &XorConn{ + Conn: conn, + CTR: ctr, + PeerCTR: peerCTR, + OutSkip: outSkip, + OutHeader: make([]byte, 0, 5), // important + InSkip: inSkip, + InHeader: make([]byte, 0, 5), // important + } +} + +func (c *XorConn) Write(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + for p := b; ; { + if len(p) <= c.OutSkip { + c.OutSkip -= len(p) + break + } + p = p[c.OutSkip:] + c.OutSkip = 0 + need := 5 - len(c.OutHeader) + if len(p) < need { + c.OutHeader = append(c.OutHeader, p...) + c.CTR.XORKeyStream(p, p) + break + } + c.OutSkip, _ = DecodeHeader(append(c.OutHeader, p[:need]...)) + c.OutHeader = c.OutHeader[:0] + c.CTR.XORKeyStream(p[:need], p[:need]) + p = p[need:] + } + if _, err := c.Conn.Write(b); err != nil { + return 0, err + } + return len(b), nil +} + +func (c *XorConn) Read(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + n, err := c.Conn.Read(b) + for p := b[:n]; ; { + if len(p) <= c.InSkip { + c.InSkip -= len(p) + break + } + p = p[c.InSkip:] + c.InSkip = 0 + need := 5 - len(c.InHeader) + if len(p) < need { + c.PeerCTR.XORKeyStream(p, p) + c.InHeader = append(c.InHeader, p...) + break + } + c.PeerCTR.XORKeyStream(p[:need], p[:need]) + c.InSkip, _ = DecodeHeader(append(c.InHeader, p[:need]...)) + c.InHeader = c.InHeader[:0] + p = p[need:] + } + return n, err +} + +// Upstream returns the underlying connection, allowing Vision to unwrap and access the TLS connection +func (c *XorConn) Upstream() any { + return c.Conn +} + +func (c *XorConn) IsEncryptionLayer() bool { + return true +} diff --git a/protocol/vless/inbound.go b/protocol/vless/inbound.go index 75cd4124..9104f47e 100644 --- a/protocol/vless/inbound.go +++ b/protocol/vless/inbound.go @@ -2,8 +2,11 @@ package vless import ( "context" + "encoding/base64" "net" "os" + "strconv" + "strings" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/inbound" @@ -14,6 +17,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/protocol/vless/encryption" "github.com/sagernet/sing-box/transport/v2ray" "github.com/sagernet/sing-vmess/packetaddr" "github.com/sagernet/sing-vmess/vless" @@ -35,14 +39,15 @@ var _ adapter.TCPInjectableInbound = (*Inbound)(nil) type Inbound struct { inbound.Adapter - ctx context.Context - router adapter.ConnectionRouterEx - logger logger.ContextLogger - listener *listener.Listener - users []option.VLESSUser - service *vless.Service[int] - tlsConfig tls.ServerConfig - transport adapter.V2RayServerTransport + ctx context.Context + router adapter.ConnectionRouterEx + logger logger.ContextLogger + listener *listener.Listener + users []option.VLESSUser + service *vless.Service[int] + tlsConfig tls.ServerConfig + transport adapter.V2RayServerTransport + decryption *encryption.ServerInstance } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VLESSInboundOptions) (adapter.Inbound, error) { @@ -88,6 +93,18 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo return nil, E.Cause(err, "create server transport: ", options.Transport.Type) } } + // Parse decryption configuration + if options.Decryption != "" && options.Decryption != "none" { + decryptionConfig, err := parseServerDecryption(options.Decryption) + if err != nil { + return nil, E.Cause(err, "parse decryption") + } + inbound.decryption = &encryption.ServerInstance{} + if err := inbound.decryption.Init(decryptionConfig.keys, decryptionConfig.xorMode, decryptionConfig.secondsFrom, decryptionConfig.secondsTo, decryptionConfig.padding); err != nil { + return nil, E.Cause(err, "initialize decryption") + } + logger.Debug("decryption initialized with ", len(decryptionConfig.keys), " keys xorMode=", decryptionConfig.xorMode, " secondsFrom=", decryptionConfig.secondsFrom, " secondsTo=", decryptionConfig.secondsTo, " padding=", decryptionConfig.padding) + } inbound.listener = listener.New(listener.Options{ Context: ctx, Logger: logger, @@ -139,6 +156,9 @@ func (h *Inbound) Start(stage adapter.StartStage) error { } func (h *Inbound) Close() error { + if h.decryption != nil { + h.decryption.Close() + } return common.Close( h.service, h.listener, @@ -147,7 +167,26 @@ 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) { + canSplice := h.transport == nil + if canSplice && h.decryption != nil && h.decryption.IsFullRandomXorMode() { + canSplice = false + } + h.newConnectionExInternal(ctx, conn, metadata, onClose, canSplice) +} + +func (h *Inbound) newConnectionExInternal(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc, canSplice bool) { if h.tlsConfig != nil && h.transport == nil { tlsConn, err := tls.ServerHandshake(ctx, conn, h.tlsConfig) if err != nil { @@ -157,7 +196,17 @@ func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata a } conn = tlsConn } - err := h.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose) + // Apply decryption if configured + if h.decryption != nil { + encConn, err := h.decryption.Handshake(conn, nil) + if err != nil { + N.CloseOnHandshakeFailure(conn, onClose, err) + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source, ": encryption handshake")) + return + } + conn = encConn + } + err := h.service.NewConnectionWithOptions(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose, canSplice) if err != nil { N.CloseOnHandshakeFailure(conn, onClose, err) h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source)) @@ -206,6 +255,90 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn, h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) } +type serverDecryptionConfig struct { + keys [][]byte + xorMode uint32 + secondsFrom int64 + secondsTo int64 + padding string +} + +func parseServerDecryption(raw string) (serverDecryptionConfig, error) { + var cfg serverDecryptionConfig + raw = strings.TrimSpace(raw) + if raw == "" { + return cfg, E.New("empty decryption string") + } + parts := strings.Split(raw, ".") + if len(parts) < 4 { + return cfg, E.New("invalid decryption string: missing components") + } + if parts[0] != "mlkem768x25519plus" { + return cfg, E.New("unsupported decryption prefix: ", parts[0]) + } + switch parts[1] { + case "native": + cfg.xorMode = 0 + case "xorpub": + cfg.xorMode = 1 + case "random": + cfg.xorMode = 2 + default: + return cfg, E.New("unknown decryption mode: ", parts[1]) + } + + secondsToken := strings.TrimSpace(parts[2]) + if secondsToken == "" { + return cfg, E.New("invalid decryption seconds segment") + } + trimmed := strings.TrimSuffix(secondsToken, "s") + if trimmed == "" { + return cfg, E.New("invalid decryption seconds segment") + } + values := strings.SplitN(trimmed, "-", 2) + secondsFrom, err := strconv.ParseInt(values[0], 10, 64) + if err != nil { + return cfg, E.Cause(err, "parse decryption seconds_from") + } + cfg.secondsFrom = secondsFrom + if len(values) == 2 && values[1] != "" { + secondsTo, err := strconv.ParseInt(values[1], 10, 64) + if err != nil { + return cfg, E.Cause(err, "parse decryption seconds_to") + } + cfg.secondsTo = secondsTo + } + + paddingPhase := true + var paddingParts []string + for _, segment := range parts[3:] { + segment = strings.TrimSpace(segment) + if segment == "" { + return cfg, E.New("invalid empty segment in decryption string") + } + if data, err := base64.RawURLEncoding.DecodeString(segment); err == nil { + if len(data) == 32 || len(data) == 64 { + cfg.keys = append(cfg.keys, data) + paddingPhase = false + continue + } + return cfg, E.New("invalid decryption key length: ", len(data)) + } + if paddingPhase { + paddingParts = append(paddingParts, segment) + continue + } + return cfg, E.New("invalid decryption key: ", segment) + } + if len(cfg.keys) == 0 { + return cfg, E.New("no valid decryption keys found in decryption string") + } + if len(paddingParts) > 0 { + cfg.padding = strings.Join(paddingParts, ".") + } + return cfg, nil +} + var _ adapter.V2RayServerTransportHandler = (*inboundTransportHandler)(nil) type inboundTransportHandler Inbound @@ -218,5 +351,5 @@ func (h *inboundTransportHandler) NewConnectionEx(ctx context.Context, conn net. metadata.InboundDetour = h.listener.ListenOptions().Detour //nolint:staticcheck h.logger.InfoContext(ctx, "inbound connection from ", metadata.Source) - (*Inbound)(h).NewConnectionEx(ctx, conn, metadata, onClose) + (*Inbound)(h).newConnectionExInternal(ctx, conn, metadata, onClose, false) } diff --git a/protocol/vless/outbound.go b/protocol/vless/outbound.go index d11d03a3..1a17baad 100644 --- a/protocol/vless/outbound.go +++ b/protocol/vless/outbound.go @@ -2,16 +2,23 @@ package vless import ( "context" + stdtls "crypto/tls" + "encoding/base64" "net" + "reflect" + "strings" + "sync" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/outbound" "github.com/sagernet/sing-box/common/dialer" "github.com/sagernet/sing-box/common/mux" "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/common/vision" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/vless/encryption" "github.com/sagernet/sing-box/transport/v2ray" "github.com/sagernet/sing-vmess/packetaddr" "github.com/sagernet/sing-vmess/vless" @@ -39,6 +46,8 @@ type Outbound struct { transport adapter.V2RayClientTransport packetAddr bool xudp bool + encryption *encryption.ClientInstance + vision bool } func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VLESSOutboundOptions) (adapter.Outbound, error) { @@ -51,6 +60,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL logger: logger, dialer: outboundDialer, serverAddr: options.ServerOptions.Build(), + vision: strings.HasPrefix(options.Flow, "xtls-rprx-vision"), } if options.TLS != nil { outbound.tlsConfig, err = tls.NewClientWithOptions(tls.ClientOptions{ @@ -86,11 +96,28 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL return nil, E.New("unknown packet encoding: ", options.PacketEncoding) } } + // Parse encryption configuration + if options.Encryption != "" && options.Encryption != "none" { + encryptionConfig, err := parseClientEncryption(options.Encryption) + if err != nil { + return nil, E.Cause(err, "parse encryption") + } + outbound.encryption = &encryption.ClientInstance{} + if err := outbound.encryption.Init(encryptionConfig.keys, encryptionConfig.xorMode, encryptionConfig.seconds, encryptionConfig.padding); err != nil { + return nil, E.Cause(err, "initialize encryption") + } + logger.Debug("encryption initialized: keys=", len(encryptionConfig.keys), " xorMode=", encryptionConfig.xorMode, " seconds=", encryptionConfig.seconds, " padding=", encryptionConfig.padding) + } + + muxOpts := common.PtrValueOrDefault(options.Multiplex) + if muxOpts.Enabled { + options.Flow = "" + } outbound.client, err = vless.NewClient(options.UUID, options.Flow, logger) if err != nil { return nil, err } - outbound.multiplexDialer, err = mux.NewClientWithOptions((*vlessDialer)(outbound), logger, common.PtrValueOrDefault(options.Multiplex)) + outbound.multiplexDialer, err = mux.NewClientWithOptions((*vlessDialer)(outbound), logger, muxOpts) if err != nil { return nil, err } @@ -147,20 +174,91 @@ func (h *vlessDialer) DialContext(ctx context.Context, network string, destinati metadata.Outbound = h.Tag() metadata.Destination = destination var conn net.Conn + var baseConn net.Conn + var hookOnce sync.Once + if h.vision { + ctx = vision.WithHook(ctx, func(tlsConn net.Conn) { + if tlsConn == nil || !isVisionTLSConn(tlsConn) { + return + } + hookOnce.Do(func() { + baseConn = tlsConn + }) + }) + } var err error if h.transport != nil { conn, err = h.transport.DialContext(ctx) + if err == nil && h.vision { + if baseConn == nil { + // Only set baseConn if the transport delivered a TLS-capable connection + if isVisionTLSConn(conn) { + h.logger.Warn("Vision enabled but hook was not called by transport, using fallback") + baseConn = conn + } + } + } } else if h.tlsDialer != nil { conn, err = h.tlsDialer.DialTLSContext(ctx, h.serverAddr) + if err == nil && h.vision && baseConn == nil { + baseConn = conn + } } else { conn, err = h.dialer.DialContext(ctx, N.NetworkTCP, h.serverAddr) } if err != nil { return nil, err } + + // Apply encryption if configured + if h.encryption != nil { + conn, err = h.encryption.Handshake(conn) + if err != nil { + return nil, E.Cause(err, "encryption handshake") + } + } + + // For Vision: wrap the connection to expose the TLS/encryption connection for vless client + var visionBaseConn net.Conn // The connection to pass to Vision (TLS or encryption layer) + var visionCanSplice bool + if h.vision { + isRAWTransport := h.transport == nil + + if baseConn != nil && !isVisionTLSConn(baseConn) { + baseConn = nil + } + if baseConn != nil { + // Has TLS/Reality: use baseConn (TLS connection) + visionBaseConn = baseConn + visionCanSplice = isRAWTransport + conn = newVisionConnWrapper(conn, baseConn) + } else if h.encryption != nil { + // Only has encryption (no TLS/Reality): use encryption layer itself + encConn := findEncryptionLayer(conn) + if encConn != nil { + visionBaseConn = encConn + if h.encryption.IsFullRandomXorMode() { + visionCanSplice = false + } else { + visionCanSplice = isRAWTransport + } + conn = newVisionConnWrapper(conn, encConn) + } else { + return nil, E.New("Vision: failed to find encryption layer") + } + } else { + return nil, E.New("Vision requires either TLS/Reality or Encryption") + } + } + switch N.NetworkName(network) { case N.NetworkTCP: h.logger.InfoContext(ctx, "outbound connection to ", destination) + if h.vision && visionBaseConn != nil { + // For Vision, we need to pass the base connection (TLS or encryption layer) + // to prepareConn so it can properly initialize VisionConn + return h.client.DialEarlyConnWithOptions(conn, visionBaseConn, destination, visionCanSplice) + } return h.client.DialEarlyConn(conn, destination) case N.NetworkUDP: h.logger.InfoContext(ctx, "outbound packet connection to ", destination) @@ -201,6 +299,14 @@ func (h *vlessDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) common.Close(conn) return nil, err } + // Apply encryption if configured + if h.encryption != nil { + conn, err = h.encryption.Handshake(conn) + if err != nil { + common.Close(conn) + return nil, E.Cause(err, "encryption handshake") + } + } if h.xudp { return h.client.DialEarlyXUDPPacketConn(conn, destination) } else if h.packetAddr { @@ -216,3 +322,152 @@ func (h *vlessDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) return h.client.DialEarlyPacketConn(conn, destination) } } + +type visionConnWrapper struct { + net.Conn + upstream net.Conn +} + +var ( + _ N.ReaderWithUpstream = (*visionConnWrapper)(nil) + _ N.WriterWithUpstream = (*visionConnWrapper)(nil) + _ common.WithUpstream = (*visionConnWrapper)(nil) +) + +func newVisionConnWrapper(conn net.Conn, upstream net.Conn) net.Conn { + if upstream == nil || conn == nil || conn == upstream { + return conn + } + return &visionConnWrapper{ + Conn: conn, + upstream: upstream, + } +} + +func (c *visionConnWrapper) Upstream() any { + return c.upstream +} + +func (c *visionConnWrapper) ReaderReplaceable() bool { + if replacer, ok := c.Conn.(N.ReaderWithUpstream); ok { + return replacer.ReaderReplaceable() + } + return true +} + +func (c *visionConnWrapper) WriterReplaceable() bool { + if replacer, ok := c.Conn.(N.WriterWithUpstream); ok { + return replacer.WriterReplaceable() + } + return true +} + +// isVisionTLSConn returns true when the provided connection exposes TLS semantics Vision expects. +func isVisionTLSConn(conn net.Conn) bool { + if conn == nil { + return false + } + if _, ok := conn.(interface{ ConnectionState() stdtls.ConnectionState }); ok { + return true + } + if _, ok := conn.(interface{ Handshake() error }); ok { + return true + } + connType := reflect.TypeOf(conn) + if connType == nil { + return false + } + if connType.Kind() == reflect.Ptr { + pkgPath := connType.Elem().PkgPath() + if pkgPath == "crypto/tls" || strings.Contains(pkgPath, "utls") || strings.Contains(pkgPath, "shadowtls") { + return true + } + } + return false +} + +func findEncryptionLayer(conn net.Conn) net.Conn { + for conn != nil { + if enc, ok := conn.(encryption.EncryptionConn); ok && enc.IsEncryptionLayer() { + return conn + } + if upstream, ok := conn.(common.WithUpstream); ok { + if next := upstream.Upstream(); next != nil { + if nextConn, ok := next.(net.Conn); ok { + conn = nextConn + continue + } + } + } + break + } + return nil +} + +type clientEncryptionConfig struct { + keys [][]byte + xorMode uint32 + seconds uint32 + padding string +} + +func parseClientEncryption(raw string) (clientEncryptionConfig, error) { + var cfg clientEncryptionConfig + raw = strings.TrimSpace(raw) + if raw == "" { + return cfg, E.New("empty encryption string") + } + parts := strings.Split(raw, ".") + if len(parts) < 4 { + return cfg, E.New("invalid encryption string: missing components") + } + if parts[0] != "mlkem768x25519plus" { + return cfg, E.New("unsupported encryption prefix: ", parts[0]) + } + switch parts[1] { + case "native": + cfg.xorMode = 0 + case "xorpub": + cfg.xorMode = 1 + case "random": + cfg.xorMode = 2 + default: + return cfg, E.New("unknown encryption mode: ", parts[1]) + } + switch parts[2] { + case "0rtt": + cfg.seconds = 1 + case "1rtt": + cfg.seconds = 0 + default: + return cfg, E.New("unsupported encryption RTT value: ", parts[2]) + } + paddingPhase := true + var paddingParts []string + for _, segment := range parts[3:] { + segment = strings.TrimSpace(segment) + if segment == "" { + return cfg, E.New("invalid empty segment in encryption string") + } + if data, err := base64.RawURLEncoding.DecodeString(segment); err == nil { + if len(data) == 32 || len(data) == 1184 { + cfg.keys = append(cfg.keys, data) + paddingPhase = false + continue + } + return cfg, E.New("invalid encryption key length: ", len(data)) + } + if paddingPhase { + paddingParts = append(paddingParts, segment) + continue + } + return cfg, E.New("invalid encryption key: ", segment) + } + if len(cfg.keys) == 0 { + return cfg, E.New("no valid encryption keys found in encryption string") + } + if len(paddingParts) > 0 { + cfg.padding = strings.Join(paddingParts, ".") + } + return cfg, nil +} diff --git a/protocol/vmess/inbound.go b/protocol/vmess/inbound.go index 4e9c763c..489a688c 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/tunnel/client.go b/protocol/vpn/client.go similarity index 55% rename from protocol/tunnel/client.go rename to protocol/vpn/client.go index 45255c1f..15007081 100644 --- a/protocol/tunnel/client.go +++ b/protocol/vpn/client.go @@ -1,15 +1,16 @@ -package tunnel +package vpn import ( "context" "net" - "os" + "net/netip" "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,39 +19,41 @@ 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" ) func RegisterClientEndpoint(registry *endpoint.Registry) { - endpoint.Register[option.TunnelClientEndpointOptions](registry, C.TypeTunnelClient, NewClientEndpoint) + endpoint.Register[option.VPNClientEndpointOptions](registry, C.TypeVPNClient, NewClientEndpoint) } 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 + address IPv4 + 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) { - clientUUID, err := uuid.FromString(options.UUID) - if err != nil { - return nil, err +func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VPNClientEndpointOptions) (adapter.Endpoint, error) { + address := options.Address + if !address.Is4() { + return nil, E.New("invalid address: ", address) } - clientKey, err := uuid.FromString(options.Key) + key, err := uuid.FromString(options.Key) if err != nil { return nil, err } client := &ClientEndpoint{ - Adapter: outbound.NewAdapter(C.TypeTunnelClient, tag, []string{N.NetworkTCP}, []string{}), + Adapter: outbound.NewAdapter(C.TypeVPNClient, tag, []string{N.NetworkTCP, N.NetworkUDP}, []string{}), ctx: ctx, - router: router, + router: sbUot.NewRouter(router, logger), logger: logger, - uuid: clientUUID, - key: clientKey, + address: address.As4(), + key: key, } outboundRegistry := service.FromContext[adapter.OutboundRegistry](ctx) outbound, err := outboundRegistry.CreateOutbound(ctx, router, logger, options.Outbound.Tag, options.Outbound.Type, options.Outbound.Options) @@ -58,6 +61,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,35 +92,42 @@ 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 { - if metadata.TunnelDestination != "" { - uuid, err := uuid.FromString(metadata.TunnelDestination) - if err != nil { - return nil, err - } - destinationUUID = &uuid - } - } - if destinationUUID == nil { - return nil, E.New("tunnel destination not set") - } - if *destinationUUID == c.uuid { - return nil, E.New("routing loop") + if destination.Addr.Is4() && destination.Addr.As4() == c.address { + return nil, E.New("routing loop on ", destination.Addr) } conn, err := c.outbound.DialContext(ctx, N.NetworkTCP, Destination) if err != nil { return nil, err } - err = WriteRequest(conn, &Request{UUID: c.key, Command: CommandTCP, DestinationUUID: *destinationUUID, Destination: destination}) - return conn, err + gateway := Loopback.As4() + if metadata := adapter.ContextFrom(ctx); metadata != nil { + if metadata.Gateway != nil { + gateway = metadata.Gateway.As4() + if gateway == c.address { + return nil, E.New("routing loop on ", destination.Addr) + } + } + } + err = WriteClientRequest( + conn, + &ClientRequest{ + Key: c.key, + Command: CommandTCP, + Gateway: gateway, + Destination: destination, + }, + ) + 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 { @@ -125,28 +139,22 @@ func (c *ClientEndpoint) startInboundConn() error { if err != nil { return err } - err = WriteRequest(conn, &Request{UUID: c.key, Command: CommandInbound, Destination: Destination}) + err = WriteClientRequest(conn, &ClientRequest{Key: c.key, Command: CommandInbound, Destination: Destination}) if err != nil { return err } - request, err := ReadRequest(conn) + request, err := ReadServerRequest(conn) if err != nil { return err } - go c.connHandler(conn, request) - return nil -} - -func (c *ClientEndpoint) connHandler(conn net.Conn, request *Request) { + if request.Source == c.address { + return E.New("routing loop") + } metadata := adapter.InboundContext{ - Source: M.ParseSocksaddr(conn.RemoteAddr().String()), + Inbound: c.Tag(), + Source: M.Socksaddr{Addr: netip.AddrFrom4(request.Source)}, Destination: request.Destination, } - if request.UUID == c.uuid { - c.logger.ErrorContext(c.ctx, "routing loop") - conn.Close() - return - } - metadata.TunnelSource = request.UUID.String() - c.router.RouteConnectionEx(c.ctx, conn, metadata, func(it error) {}) + go c.router.RouteConnectionEx(c.ctx, conn, metadata, func(it error) {}) + return nil } diff --git a/protocol/vpn/protocol.go b/protocol/vpn/protocol.go new file mode 100644 index 00000000..f6b3e210 --- /dev/null +++ b/protocol/vpn/protocol.go @@ -0,0 +1,124 @@ +package vpn + +import ( + "encoding/binary" + "io" + "net/netip" + + "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 +) + +const ( + CommandInbound = 1 + CommandTCP = 2 +) + +type IPv4 [4]byte + +var Destination = M.Socksaddr{ + Fqdn: "sp.vpn.sing-box.arpa", + Port: 444, +} + +var Loopback = netip.AddrFrom4([4]byte{127, 0, 0, 1}) + +var AddressSerializer = M.NewSerializer( + M.AddressFamilyByte(0x01, M.AddressFamilyIPv4), + M.AddressFamilyByte(0x03, M.AddressFamilyIPv6), + M.AddressFamilyByte(0x02, M.AddressFamilyFqdn), + M.PortThenAddress(), +) + +type ClientRequest struct { + Key uuid.UUID + Command byte + Gateway IPv4 + Destination M.Socksaddr +} + +func ReadClientRequest(reader io.Reader) (*ClientRequest, error) { + var request ClientRequest + 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.Key[:]) + if err != nil { + return nil, err + } + err = binary.Read(reader, binary.BigEndian, &request.Command) + if err != nil { + return nil, err + } + _, err = io.ReadFull(reader, request.Gateway[:]) + if err != nil { + return nil, err + } + request.Destination, err = AddressSerializer.ReadAddrPort(reader) + if err != nil { + return nil, err + } + return &request, nil +} + +func WriteClientRequest(writer io.Writer, request *ClientRequest) error { + var requestLen int + requestLen += 1 // version + requestLen += 16 // key + requestLen += 1 // command + requestLen += 4 // gateway + requestLen += AddressSerializer.AddrPortLen(request.Destination) + buffer := buf.NewSize(requestLen) + defer buffer.Release() + common.Must( + buffer.WriteByte(Version), + common.Error(buffer.Write(request.Key[:])), + buffer.WriteByte(request.Command), + common.Error(buffer.Write(request.Gateway[:])), + AddressSerializer.WriteAddrPort(buffer, request.Destination), + ) + return common.Error(writer.Write(buffer.Bytes())) +} + +type ServerRequest struct { + Source IPv4 + Destination M.Socksaddr +} + +func ReadServerRequest(reader io.Reader) (*ServerRequest, error) { + var request ServerRequest + _, err := io.ReadFull(reader, request.Source[:]) + if err != nil { + return nil, err + } + request.Destination, err = AddressSerializer.ReadAddrPort(reader) + if err != nil { + return nil, err + } + return &request, nil +} + +func WriteServerRequest(writer io.Writer, request *ServerRequest) error { + var requestLen int + requestLen += 4 // source + requestLen += AddressSerializer.AddrPortLen(request.Destination) + buffer := buf.NewSize(requestLen) + defer buffer.Release() + common.Must( + common.Error(buffer.Write(request.Source[:])), + AddressSerializer.WriteAddrPort(buffer, request.Destination), + ) + return common.Error(writer.Write(buffer.Bytes())) +} diff --git a/protocol/tunnel/router.go b/protocol/vpn/router.go similarity index 75% rename from protocol/tunnel/router.go rename to protocol/vpn/router.go index e2e708b5..5e0d2a87 100644 --- a/protocol/tunnel/router.go +++ b/protocol/vpn/router.go @@ -1,4 +1,4 @@ -package tunnel +package vpn import ( "context" @@ -21,14 +21,24 @@ func NewRouter(router adapter.Router, logger logger.ContextLogger, handler func( } func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { + if metadata.Destination != Destination { + return r.Router.RouteConnection(ctx, conn, metadata) + } return r.handler(ctx, conn, metadata, func(error) {}) } func (r *Router) RoutePacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error { + if metadata.Destination != Destination { + return r.Router.RoutePacketConnection(ctx, conn, metadata) + } return os.ErrInvalid } func (r *Router) RouteConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + if metadata.Destination != Destination { + r.Router.RouteConnectionEx(ctx, conn, metadata, onClose) + return + } if err := r.handler(ctx, conn, metadata, onClose); err != nil { r.logger.ErrorContext(ctx, err) N.CloseOnHandshakeFailure(conn, onClose, err) @@ -36,6 +46,10 @@ func (r *Router) RouteConnectionEx(ctx context.Context, conn net.Conn, metadata } func (r *Router) RoutePacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + if metadata.Destination != Destination { + r.Router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) + return + } r.logger.ErrorContext(ctx, os.ErrInvalid) N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) } diff --git a/protocol/vpn/server.go b/protocol/vpn/server.go new file mode 100644 index 00000000..872dd83e --- /dev/null +++ b/protocol/vpn/server.go @@ -0,0 +1,235 @@ +package vpn + +import ( + "context" + "errors" + "net" + "net/netip" + "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" + 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 RegisterServerEndpoint(registry *endpoint.Registry) { + endpoint.Register[option.VPNServerEndpointOptions](registry, C.TypeVPNServer, NewServerEndpoint) +} + +type ServerEndpoint struct { + outbound.Adapter + logger logger.ContextLogger + inbounds []adapter.Inbound + router adapter.ConnectionRouterEx + address IPv4 + addresses map[uuid.UUID]IPv4 + keys map[IPv4]uuid.UUID + conns map[IPv4]chan net.Conn + timeout time.Duration + uotClient *uot.Client +} + +func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VPNServerEndpointOptions) (adapter.Endpoint, error) { + address := options.Address + if !address.Is4() { + return nil, E.New("invalid address: ", address) + } + server := &ServerEndpoint{ + Adapter: outbound.NewAdapter(C.TypeVPNServer, tag, []string{N.NetworkTCP, N.NetworkUDP}, []string{}), + logger: logger, + router: sbUot.NewRouter(router, logger), + address: address.As4(), + } + router = NewRouter(router, logger, server.connHandler) + inboundRegistry := service.FromContext[adapter.InboundRegistry](ctx) + inbounds := make([]adapter.Inbound, len(options.Inbounds)) + for i, inboundOptions := range options.Inbounds { + inbound, err := inboundRegistry.Create(ctx, router, logger, inboundOptions.Tag, inboundOptions.Type, inboundOptions.Options) + if err != nil { + return nil, err + } + inbounds[i] = inbound + } + server.inbounds = inbounds + server.addresses = make(map[uuid.UUID]IPv4, len(options.Users)) + server.keys = make(map[IPv4]uuid.UUID, len(options.Users)) + server.conns = make(map[IPv4]chan net.Conn) + for _, user := range options.Users { + key, err := uuid.FromString(user.Key) + if err != nil { + return nil, err + } + if !user.Address.Is4() { + return nil, E.New("invalid address: ", user.Address) + } + address := user.Address.As4() + server.addresses[key] = address + server.keys[address] = key + server.conns[address] = make(chan net.Conn, 10) + } + if options.ConnectTimeout != 0 { + server.timeout = time.Duration(options.ConnectTimeout) + } else { + server.timeout = C.TCPConnectTimeout + } + server.uotClient = &uot.Client{ + Dialer: server, + Version: uot.Version, + } + return server, nil +} + +func (s *ServerEndpoint) Start(stage adapter.StartStage) error { + for _, inbound := range s.inbounds { + err := inbound.Start(stage) + if err != nil { + return err + } + } + return nil +} + +func (s *ServerEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if N.NetworkName(network) == N.NetworkUDP { + return s.uotClient.DialContext(ctx, network, destination) + } + source := s.address + var gateway *netip.Addr + if metadata := adapter.ContextFrom(ctx); metadata != nil { + if metadata.Source.IsIPv4() { + address := metadata.Source.Addr.As4() + if _, ok := s.conns[address]; ok { + source = address + } + } + if metadata.Gateway != nil { + gateway = metadata.Gateway + } + } + if gateway == nil { + if destination.IsIPv4() { + gateway = &destination.Addr + destination = M.Socksaddr{ + Addr: Loopback, + Port: destination.Port, + } + } else { + return nil, E.New("missing gateway") + } + } else if destination.Addr.Compare(*gateway) == 0 { + destination = M.Socksaddr{ + Addr: Loopback, + Port: destination.Port, + } + } + if gateway.Compare(Loopback) == 0 { + return nil, E.New("invalid gateway") + } + ch, ok := s.conns[gateway.As4()] + if !ok { + return nil, E.New("user with address ", gateway, " not found") + } + ctx, cancel := context.WithTimeout(ctx, s.timeout) + defer cancel() + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + select { + case conn := <-ch: + err := WriteServerRequest(conn, &ServerRequest{Source: source, Destination: destination}) + if err != nil { + conn.Close() + s.logger.ErrorContext(ctx, err) + continue + } + return conn, nil + case <-ctx.Done(): + return nil, ctx.Err() + } + } +} + +func (s *ServerEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return s.uotClient.ListenPacket(ctx, destination) +} + +func (s *ServerEndpoint) Close() error { + errs := make([]error, 0) + for _, inbound := range s.inbounds { + err := inbound.Close() + if err != nil { + errs = append(errs, err) + } + } + if len(errs) != 0 { + return errors.Join(errs...) + } + return nil +} + +func (s *ServerEndpoint) connHandler(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) error { + if metadata.Destination != Destination { + s.router.RouteConnectionEx(ctx, conn, metadata, onClose) + return nil + } + request, err := ReadClientRequest(conn) + if err != nil { + return err + } + if request.Command == CommandInbound { + address, ok := s.addresses[request.Key] + if !ok { + return E.New("key ", request.Key.String(), " not found") + } + ch := s.conns[address] + select { + case ch <- conn: + default: + oldConn := <-ch + oldConn.Close() + ch <- conn + } + return nil + } + if request.Command == CommandTCP { + source, ok := s.addresses[request.Key] + if !ok { + return E.New("key ", request.Key, " not found") + } + if request.Destination.Addr.Is4() && source == request.Destination.Addr.As4() { + return E.New("routing loop on ", request.Destination) + } + metadata.Inbound = s.Tag() + metadata.InboundType = C.TypeVPNServer + metadata.Source = M.Socksaddr{Addr: netip.AddrFrom4(source)} + if request.Destination.Addr.Is4() && request.Destination.Addr.As4() == s.address { + metadata.Destination = M.Socksaddr{ + Addr: Loopback, + Port: request.Destination.Port, + } + } else { + metadata.Destination = request.Destination + if request.Gateway != s.address && request.Gateway != Loopback.As4() { + addr := netip.AddrFrom4(request.Gateway) + metadata.Gateway = &addr + } + } + s.router.RouteConnectionEx(ctx, conn, metadata, onClose) + return nil + } + return E.New("command ", request.Command, " not found") +} diff --git a/protocol/warp/config.go b/protocol/warp/config.go new file mode 100644 index 00000000..eadd9824 --- /dev/null +++ b/protocol/warp/config.go @@ -0,0 +1,14 @@ +package warp + +import "github.com/sagernet/sing-box/common/cloudflare" + +type Config struct { + PrivateKey string `json:"private_key"` + Interface struct { + Addresses struct { + V4 string `json:"v4"` + V6 string `json:"v6"` + } `json:"addresses"` + } `json:"interface"` + Peers []cloudflare.Peer +} diff --git a/protocol/wireguard/endpoint_warp.go b/protocol/warp/endpoint.go similarity index 51% rename from protocol/wireguard/endpoint_warp.go rename to protocol/warp/endpoint.go index 690ffb66..8b417813 100644 --- a/protocol/wireguard/endpoint_warp.go +++ b/protocol/warp/endpoint.go @@ -1,4 +1,4 @@ -package wireguard +package warp import ( "context" @@ -7,7 +7,6 @@ import ( "net" "net/netip" "strings" - "sync" "time" "github.com/sagernet/sing-box/adapter" @@ -16,6 +15,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/protocol/wireguard" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json/badoption" @@ -25,19 +25,21 @@ import ( "golang.zx2c4.com/wireguard/wgctrl/wgtypes" ) -func RegisterWARPEndpoint(registry *endpoint.Registry) { - endpoint.Register[option.WireGuardWARPEndpointOptions](registry, C.TypeWARP, NewWARPEndpoint) +func RegisterEndpoint(registry *endpoint.Registry) { + endpoint.Register[option.WARPEndpointOptions](registry, C.TypeWARP, NewEndpoint) } -type WARPEndpoint struct { +type Endpoint struct { endpoint.Adapter + ctx context.Context + options option.WARPEndpointOptions endpoint adapter.Endpoint startHandler func() - mtx sync.Mutex + await chan struct{} } -func NewWARPEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.WireGuardWARPEndpointOptions) (adapter.Endpoint, error) { +func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.WARPEndpointOptions) (adapter.Endpoint, error) { var dependencies []string if options.Detour != "" { dependencies = append(dependencies, options.Detour) @@ -45,14 +47,16 @@ func NewWARPEndpoint(ctx context.Context, router adapter.Router, logger log.Cont if options.Profile.Detour != "" { dependencies = append(dependencies, options.Profile.Detour) } - warpEndpoint := &WARPEndpoint{ + endpoint := &Endpoint{ Adapter: endpoint.NewAdapter(C.TypeWARP, tag, []string{N.NetworkTCP, N.NetworkUDP}, dependencies), + ctx: ctx, + options: options, + await: make(chan struct{}), } - warpEndpoint.mtx.Lock() - warpEndpoint.startHandler = func() { - defer warpEndpoint.mtx.Unlock() + endpoint.startHandler = func() { + defer close(endpoint.await) cacheFile := service.FromContext[adapter.CacheFile](ctx) - var config *C.WARPConfig + var config *Config var err error if !options.Profile.Recreate && cacheFile != nil && cacheFile.StoreWARPConfig() { savedProfile := cacheFile.LoadWARPConfig(tag) @@ -64,50 +68,10 @@ func NewWARPEndpoint(ctx context.Context, router adapter.Router, logger log.Cont } } if config == nil { - var privateKey wgtypes.Key - if options.Profile.PrivateKey != "" { - privateKey, err = wgtypes.ParseKey(options.Profile.PrivateKey) - if err != nil { - logger.ErrorContext(ctx, err) - return - } - } else { - privateKey, err = wgtypes.GeneratePrivateKey() - if err != nil { - logger.ErrorContext(ctx, err) - return - } - } - opts := make([]cloudflare.CloudflareApiOption, 0, 1) - if options.Profile.Detour != "" { - detour, ok := service.FromContext[adapter.OutboundManager](ctx).Outbound(options.Profile.Detour) - if !ok { - logger.ErrorContext(ctx, E.New("outbound detour not found: ", options.Profile.Detour)) - return - } - opts = append(opts, cloudflare.WithDialContext(func(ctx context.Context, network, addr string) (net.Conn, error) { - return detour.DialContext(ctx, network, M.ParseSocksaddr(addr)) - })) - } - api := cloudflare.NewCloudflareApi(opts...) - var profile *cloudflare.CloudflareProfile - if options.Profile.AuthToken != "" && options.Profile.ID != "" { - profile, err = api.GetProfile(ctx, options.Profile.AuthToken, options.Profile.ID) - if err != nil { - logger.ErrorContext(ctx, err) - return - } - } else { - profile, err = api.CreateProfile(ctx, privateKey.PublicKey().String()) - if err != nil { - logger.ErrorContext(ctx, err) - return - } - } - config = &C.WARPConfig{ - PrivateKey: privateKey.String(), - Interface: profile.Config.Interface, - Peers: profile.Config.Peers, + config, err := endpoint.createConfig() + if err != nil { + logger.ErrorContext(ctx, err) + return } if cacheFile != nil && cacheFile.StoreWARPConfig() { content, err := json.Marshal(config) @@ -124,7 +88,7 @@ func NewWARPEndpoint(ctx context.Context, router adapter.Router, logger log.Cont } peer := config.Peers[0] hostParts := strings.Split(peer.Endpoint.Host, ":") - warpEndpoint.endpoint, err = NewEndpoint( + endpoint.endpoint, err = wireguard.NewEndpoint( ctx, router, logger, @@ -154,6 +118,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, @@ -163,19 +129,19 @@ func NewWARPEndpoint(ctx context.Context, router adapter.Router, logger log.Cont logger.ErrorContext(ctx, err) return } - if err = warpEndpoint.endpoint.Start(adapter.StartStateStart); err != nil { + if err = endpoint.endpoint.Start(adapter.StartStateStart); err != nil { logger.ErrorContext(ctx, err) return } - if err = warpEndpoint.endpoint.Start(adapter.StartStatePostStart); err != nil { + if err = endpoint.endpoint.Start(adapter.StartStatePostStart); err != nil { logger.ErrorContext(ctx, err) return } } - return warpEndpoint, nil + return endpoint, nil } -func (w *WARPEndpoint) Start(stage adapter.StartStage) error { +func (w *Endpoint) Start(stage adapter.StartStage) error { if stage != adapter.StartStatePostStart { return nil } @@ -183,26 +149,79 @@ func (w *WARPEndpoint) Start(stage adapter.StartStage) error { return nil } -func (w *WARPEndpoint) Close() error { +func (w *Endpoint) Close() error { + if err := w.isEndpointInitialized(w.ctx); err != nil { + return err + } return common.Close(w.endpoint) } -func (w *WARPEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { - if ok := w.isEndpointInitialized(); !ok { - return nil, E.New("endpoint not initialized") +func (w *Endpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if err := w.isEndpointInitialized(ctx); err != nil { + return nil, err } return w.endpoint.DialContext(ctx, network, destination) } -func (w *WARPEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { - if ok := w.isEndpointInitialized(); !ok { - return nil, E.New("endpoint not initialized") +func (w *Endpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if err := w.isEndpointInitialized(ctx); err != nil { + return nil, err } return w.endpoint.ListenPacket(ctx, destination) } -func (w *WARPEndpoint) isEndpointInitialized() bool { - w.mtx.Lock() - defer w.mtx.Unlock() - return w.endpoint != nil +func (w *Endpoint) isEndpointInitialized(ctx context.Context) error { + select { + case <-w.await: + case <-ctx.Done(): + return ctx.Err() + } + if w.endpoint == nil { + return E.New("endpoint not initialized") + } + return nil +} + +func (w *Endpoint) createConfig() (*Config, error) { + var privateKey wgtypes.Key + var err error + if w.options.Profile.PrivateKey != "" { + privateKey, err = wgtypes.ParseKey(w.options.Profile.PrivateKey) + if err != nil { + return nil, err + } + } else { + privateKey, err = wgtypes.GeneratePrivateKey() + if err != nil { + return nil, err + } + } + opts := make([]cloudflare.CloudflareApiOption, 0, 1) + if w.options.Profile.Detour != "" { + detour, ok := service.FromContext[adapter.OutboundManager](w.ctx).Outbound(w.options.Profile.Detour) + if !ok { + return nil, E.New("outbound detour not found: ", w.options.Profile.Detour) + } + opts = append(opts, cloudflare.WithDialContext(func(ctx context.Context, network, addr string) (net.Conn, error) { + return detour.DialContext(ctx, network, M.ParseSocksaddr(addr)) + })) + } + api := cloudflare.NewCloudflareApi(opts...) + var profile *cloudflare.CloudflareProfile + if w.options.Profile.AuthToken != "" && w.options.Profile.ID != "" { + profile, err = api.GetProfile(w.ctx, w.options.Profile.AuthToken, w.options.Profile.ID) + if err != nil { + return nil, err + } + } else { + profile, err = api.CreateProfile(w.ctx, privateKey.PublicKey().String()) + if err != nil { + return nil, err + } + } + return &Config{ + PrivateKey: privateKey.String(), + Interface: profile.Config.Interface, + Peers: profile.Config.Peers, + }, nil } diff --git a/provider/local/provider.go b/provider/local/provider.go new file mode 100644 index 00000000..a8a9f405 --- /dev/null +++ b/provider/local/provider.go @@ -0,0 +1,129 @@ +package provider + +import ( + "context" + "os" + "path/filepath" + "time" + + "github.com/sagernet/fswatch" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/provider" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/parser" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" +) + +func RegisterProviderLocal(registry *provider.Registry) { + provider.Register[option.ProviderLocalOptions](registry, C.ProviderTypeLocal, NewProviderLocal) +} + +func RegisterProviderInline(registry *provider.Registry) { + provider.Register[option.ProviderInlineOptions](registry, C.ProviderTypeInline, NewProviderInline) +} + +var _ adapter.Provider = (*ProviderLocal)(nil) + +type ProviderLocal struct { + provider.Adapter + ctx context.Context + logger log.ContextLogger + provider adapter.ProviderManager + path string + lastOutOpts []option.Outbound + lastUpdated time.Time + watcher *fswatch.Watcher +} + +func NewProviderInline(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options option.ProviderInlineOptions) (adapter.Provider, error) { + var ( + outbound = service.FromContext[adapter.OutboundManager](ctx) + logger = logFactory.NewLogger(F.ToString("provider/inline", "[", tag, "]")) + ) + provider := &ProviderLocal{ + Adapter: provider.NewAdapter(ctx, router, outbound, logFactory, logger, tag, C.ProviderTypeInline, options.HealthCheck), + ctx: ctx, + logger: logger, + } + provider.UpdateOutbounds(nil, options.Outbounds) + return provider, nil +} + +func NewProviderLocal(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options option.ProviderLocalOptions) (adapter.Provider, error) { + if options.Path == "" { + return nil, E.New("provider path is required") + } + var ( + outbound = service.FromContext[adapter.OutboundManager](ctx) + logger = logFactory.NewLogger(F.ToString("provider/local", "[", tag, "]")) + ) + provider := &ProviderLocal{ + Adapter: provider.NewAdapter(ctx, router, outbound, logFactory, logger, tag, C.ProviderTypeLocal, options.HealthCheck), + ctx: ctx, + logger: logger, + provider: service.FromContext[adapter.ProviderManager](ctx), + } + filePath := filemanager.BasePath(ctx, options.Path) + provider.path, _ = filepath.Abs(filePath) + watcher, err := fswatch.NewWatcher(fswatch.Options{ + Path: []string{filePath}, + Callback: func(path string) { + uErr := provider.reloadFile(path) + if uErr != nil { + logger.Error(E.Cause(uErr, "reload provider ", tag)) + } + provider.UpdateGroups() + }, + }) + if err != nil { + return nil, err + } + provider.watcher = watcher + return provider, nil +} + +func (s *ProviderLocal) Start() error { + err := s.reloadFile(s.path) + if err != nil { + return err + } + s.UpdateGroups() + if s.watcher != nil { + err := s.watcher.Start() + if err != nil { + s.logger.Error(E.Cause(err, "watch provider file")) + } + } + return s.Adapter.Start() +} + +func (s *ProviderLocal) UpdatedAt() time.Time { + return s.lastUpdated +} + +func (s *ProviderLocal) reloadFile(path string) error { + if fileInfo, err := os.Stat(path); err == nil { + s.lastUpdated = fileInfo.ModTime() + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + outboundOpts, err := parser.ParseSubscription(s.ctx, string(content)) + if err != nil { + return err + } + s.UpdateOutbounds(s.lastOutOpts, outboundOpts) + s.lastOutOpts = outboundOpts + return nil +} + +func (s *ProviderLocal) Close() error { + return common.Close(&s.Adapter, common.PtrOrNil(s.watcher)) +} diff --git a/provider/remote/provider.go b/provider/remote/provider.go new file mode 100644 index 00000000..906c333e --- /dev/null +++ b/provider/remote/provider.go @@ -0,0 +1,338 @@ +package provider + +import ( + "bytes" + "context" + "crypto/tls" + "io" + "net" + "net/http" + "regexp" + "runtime" + "strings" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/provider" + boxCommon "github.com/sagernet/sing-box/common" + "github.com/sagernet/sing-box/common/interrupt" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/parser" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/sing/service" +) + +func RegisterProvider(registry *provider.Registry) { + provider.Register[option.ProviderRemoteOptions](registry, C.ProviderTypeRemote, NewProviderRemote) +} + +var _ adapter.Provider = (*ProviderRemote)(nil) + +type ProviderRemote struct { + provider.Adapter + ctx context.Context + cancel context.CancelFunc + logger log.ContextLogger + outbound adapter.OutboundManager + provider adapter.ProviderManager + cacheFile adapter.CacheFile + dialer N.Dialer + lastEtag string + lastOutOpts []option.Outbound + lastUpdated time.Time + subscriptionInfo adapter.SubscriptionInfo + ticker *time.Ticker + updating atomic.Bool + + url string + userAgent string + downloadDetour string + updateInterval time.Duration + exclude *regexp.Regexp + include *regexp.Regexp +} + +func NewProviderRemote(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options option.ProviderRemoteOptions) (adapter.Provider, error) { + if options.URL == "" { + return nil, E.New("provider URL is required") + } + updateInterval := time.Duration(options.UpdateInterval) + if updateInterval <= 0 { + updateInterval = 24 * time.Hour + } + if updateInterval < time.Minute { + updateInterval = time.Minute + } + var userAgent string + if options.UserAgent == "" { + userAgent = "sing-box " + C.Version + } else { + userAgent = options.UserAgent + } + ctx, cancel := context.WithCancel(ctx) + outbound := service.FromContext[adapter.OutboundManager](ctx) + logger := logFactory.NewLogger(F.ToString("provider/remote", "[", tag, "]")) + updateChan := make(chan struct{}) + close(updateChan) + return &ProviderRemote{ + Adapter: provider.NewAdapter(ctx, router, outbound, logFactory, logger, tag, C.ProviderTypeRemote, options.HealthCheck), + ctx: ctx, + cancel: cancel, + logger: logger, + outbound: outbound, + provider: service.FromContext[adapter.ProviderManager](ctx), + + url: options.URL, + userAgent: userAgent, + downloadDetour: options.DownloadDetour, + updateInterval: updateInterval, + exclude: (*regexp.Regexp)(options.Exclude), + include: (*regexp.Regexp)(options.Include), + }, nil +} + +func (s *ProviderRemote) Start() error { + s.cacheFile = service.FromContext[adapter.CacheFile](s.ctx) + if s.cacheFile != nil { + if saveSub := s.cacheFile.LoadSubscription(s.Tag()); saveSub != nil { + content, _ := boxCommon.DecodeBase64URLSafe(string(saveSub.Content)) + firstLine, others := getFirstLine(content) + if info, ok := parseInfo(firstLine); ok { + s.subscriptionInfo = info + content, _ = boxCommon.DecodeBase64URLSafe(others) + } + if err := s.updateProviderFromContent(content); err != nil { + return E.Cause(err, "restore cached outbound provider") + } + s.UpdateGroups() + s.lastUpdated, s.lastEtag = saveSub.LastUpdated, saveSub.LastEtag + } + } + if s.downloadDetour != "" { + outbound, loaded := s.outbound.Outbound(s.downloadDetour) + if !loaded { + return E.New("detour outbound not found: ", s.downloadDetour) + } + s.dialer = outbound + } else { + s.dialer = s.outbound.Default() + } + + go s.loopUpdate() + return s.Adapter.Start() +} + +func (s *ProviderRemote) Update() error { + if s.ticker != nil { + s.ticker.Reset(s.updateInterval) + } + ctx := interrupt.ContextWithIsProviderConnection(s.ctx) + return s.fetch(ctx) +} + +func (s *ProviderRemote) UpdatedAt() time.Time { + return s.lastUpdated +} + +func (s *ProviderRemote) SubscriptionInfo() adapter.SubscriptionInfo { + return s.subscriptionInfo +} + +func (s *ProviderRemote) Close() error { + s.cancel() + if s.ticker != nil { + s.ticker.Stop() + } + return common.Close(&s.Adapter) +} + +func (s *ProviderRemote) updateOnce() { + ctx := interrupt.ContextWithIsProviderConnection(s.ctx) + if err := s.fetch(ctx); err != nil { + s.logger.Error("update outbound provider: ", err) + } +} + +func (s *ProviderRemote) fetch(ctx context.Context) error { + if s.updating.Swap(true) { + return E.New("provider is updating") + } + defer s.updating.Store(false) + s.logger.Debug("updating outbound provider ", s.Tag(), " from URL: ", s.url) + client := &http.Client{ + Transport: &http.Transport{ + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: C.TCPTimeout, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return s.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + }, + TLSClientConfig: &tls.Config{ + Time: ntp.TimeFuncFromContext(ctx), + RootCAs: adapter.RootPoolFromContext(ctx), + }, + }, + } + req, err := http.NewRequest(http.MethodGet, s.url, nil) + if err != nil { + return err + } + if s.lastEtag != "" { + req.Header.Set("If-None-Match", s.lastEtag) + } + req.Header.Set("User-Agent", s.userAgent) + resp, err := client.Do(req.WithContext(ctx)) + if err != nil { + return err + } + infoStr := resp.Header.Get("subscription-userinfo") + info, hasInfo := parseInfo(infoStr) + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNotModified: + s.subscriptionInfo = info + s.lastUpdated = time.Now() + if s.cacheFile != nil { + saveSub := s.cacheFile.LoadSubscription(s.Tag()) + if saveSub != nil { + if hasInfo { + index := bytes.IndexByte(saveSub.Content, '\n') + if index != -1 { + saveSub.Content = append([]byte(infoStr+"\n"), saveSub.Content[index+1:]...) + } + } + saveSub.LastUpdated = s.lastUpdated + err := s.cacheFile.SaveSubscription(s.Tag(), saveSub) + if err != nil { + s.logger.Error("save outbound provider cache file: ", err) + } + } + } + s.logger.Info("update outbound provider ", s.Tag(), ": not modified") + return nil + default: + return E.New("unexpected status: ", resp.Status) + } + defer resp.Body.Close() + contentRaw, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + eTagHeader := resp.Header.Get("Etag") + if eTagHeader != "" { + s.lastEtag = eTagHeader + } + content, _ := boxCommon.DecodeBase64URLSafe(string(contentRaw)) + if !hasInfo { + firstLine, others := getFirstLine(content) + if info, hasInfo = parseInfo(firstLine); hasInfo { + infoStr = firstLine + content, _ = boxCommon.DecodeBase64URLSafe(others) + } + } + if err := s.updateProviderFromContent(content); err != nil { + return err + } + s.UpdateGroups() + s.subscriptionInfo = info + s.lastUpdated = time.Now() + if s.cacheFile != nil { + content, _ := json.Marshal(option.Options{ + Outbounds: s.lastOutOpts, + }) + if hasInfo { + content = append([]byte(infoStr+"\n"), content...) + } + err = s.cacheFile.SaveSubscription(s.Tag(), &adapter.SavedBinary{ + LastUpdated: s.lastUpdated, + Content: content, + LastEtag: s.lastEtag, + }) + if err != nil { + s.logger.Error("save outbound provider cache file: ", err) + } + } + s.logger.Info("updated outbound provider ", s.Tag()) + return nil +} + +func (s *ProviderRemote) loopUpdate() { + if time.Since(s.lastUpdated) < s.updateInterval { + select { + case <-s.ctx.Done(): + return + case <-time.After(time.Until(s.lastUpdated.Add(s.updateInterval))): + s.updateOnce() + } + } else { + s.updateOnce() + } + s.ticker = time.NewTicker(s.updateInterval) + for { + runtime.GC() + select { + case <-s.ctx.Done(): + return + case <-s.ticker.C: + s.updateOnce() + } + } +} + +func (s *ProviderRemote) updateProviderFromContent(content string) error { + outboundOpts, err := parser.ParseSubscription(s.ctx, content) + if err != nil { + return err + } + outboundOpts = common.Filter(outboundOpts, func(it option.Outbound) bool { + return (s.exclude == nil || !s.exclude.MatchString(it.Tag)) && (s.include == nil || s.include.MatchString(it.Tag)) + }) + s.UpdateOutbounds(s.lastOutOpts, outboundOpts) + s.lastOutOpts = outboundOpts + return nil +} + +func getFirstLine(content string) (string, string) { + lines := strings.Split(content, "\n") + if len(lines) == 1 { + return lines[0], "" + } + others := strings.Join(lines[1:], "\n") + return lines[0], others +} + +func parseInfo(infoStr string) (adapter.SubscriptionInfo, bool) { + info := adapter.SubscriptionInfo{} + if infoStr == "" { + return info, false + } + reg := regexp.MustCompile(`(upload|download|total|expire)[\s\t]*=[\s\t]*(-?\d*);?`) + matches := reg.FindAllStringSubmatch(infoStr, 4) + if len(matches) == 0 { + return info, false + } + for _, match := range matches { + key, value := match[1], match[2] + switch key { + case "upload": + info.Upload = boxCommon.StringToType[int64](value) + case "download": + info.Download = boxCommon.StringToType[int64](value) + case "total": + info.Total = boxCommon.StringToType[int64](value) + case "expire": + info.Expire = boxCommon.StringToType[int64](value) + default: + return info, false + } + } + return info, true +} diff --git a/release/DEFAULT_BUILD_TAGS_OTHERS b/release/DEFAULT_BUILD_TAGS_OTHERS index 814b53f0..85ce0505 100644 --- a/release/DEFAULT_BUILD_TAGS_OTHERS +++ b/release/DEFAULT_BUILD_TAGS_OTHERS @@ -1 +1 @@ -with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,badlinkname,tfogo_checklinkname0 \ No newline at end of file +with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_manager,with_admin_panel,with_ccm,with_ocm,badlinkname,tfogo_checklinkname0 \ No newline at end of file diff --git a/route/conn.go b/route/conn.go index d51517c3..fa295bc0 100644 --- a/route/conn.go +++ b/route/conn.go @@ -13,7 +13,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" diff --git a/route/route.go b/route/route.go index c8291b0a..67027337 100644 --- a/route/route.go +++ b/route/route.go @@ -12,10 +12,10 @@ import ( "github.com/sagernet/sing-box/common/sniff" C "github.com/sagernet/sing-box/constant" R "github.com/sagernet/sing-box/route/rule" - "github.com/sagernet/sing-mux" + mux "github.com/sagernet/sing-mux" "github.com/sagernet/sing-tun" "github.com/sagernet/sing-tun/ping" - "github.com/sagernet/sing-vmess" + vmess "github.com/sagernet/sing-vmess" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/buf" "github.com/sagernet/sing/common/bufio" @@ -138,12 +138,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 { @@ -264,12 +263,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) @@ -487,8 +485,8 @@ match: Fqdn: metadata.Destination.Fqdn, } } - if routeOptions.OverrideTunnelDestination != "" { - metadata.TunnelDestination = routeOptions.OverrideTunnelDestination + if routeOptions.OverrideGateway.IsValid() { + metadata.Gateway = routeOptions.OverrideGateway } if routeOptions.NetworkStrategy != nil { metadata.NetworkStrategy = routeOptions.NetworkStrategy diff --git a/route/router.go b/route/router.go index bc19b5d3..04d41322 100644 --- a/route/router.go +++ b/route/router.go @@ -33,7 +33,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 @@ -56,6 +58,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), @@ -168,6 +171,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/route/rule/rule_action.go b/route/rule/rule_action.go index c671f367..fb60a4d7 100644 --- a/route/rule/rule_action.go +++ b/route/rule/rule_action.go @@ -29,12 +29,13 @@ func NewRuleAction(ctx context.Context, logger logger.ContextLogger, action opti case "": return nil, nil case C.RuleActionTypeRoute: + overrideGateway := M.ParseAddr(action.RouteOptions.OverrideGateway) return &RuleActionRoute{ Outbound: action.RouteOptions.Outbound, RuleActionRouteOptions: RuleActionRouteOptions{ OverrideAddress: M.ParseSocksaddrHostPort(action.RouteOptions.OverrideAddress, 0), OverridePort: action.RouteOptions.OverridePort, - OverrideTunnelDestination: action.RouteOptions.OverrideTunnelDestination, + OverrideGateway: &overrideGateway, NetworkStrategy: (*C.NetworkStrategy)(action.RouteOptions.NetworkStrategy), FallbackDelay: time.Duration(action.RouteOptions.FallbackDelay), UDPDisableDomainUnmapping: action.RouteOptions.UDPDisableDomainUnmapping, @@ -196,7 +197,7 @@ func (r *RuleActionBypass) String() string { type RuleActionRouteOptions struct { OverrideAddress M.Socksaddr OverridePort uint16 - OverrideTunnelDestination string + OverrideGateway *netip.Addr NetworkStrategy *C.NetworkStrategy NetworkType []C.InterfaceType FallbackNetworkType []C.InterfaceType @@ -225,8 +226,8 @@ func (r *RuleActionRouteOptions) Descriptions() []string { if r.OverridePort > 0 { descriptions = append(descriptions, F.ToString("override-port=", r.OverridePort)) } - if r.OverrideTunnelDestination != "" { - descriptions = append(descriptions, F.ToString("override-tunnel-destination=", r.OverrideTunnelDestination)) + if r.OverrideGateway != nil { + descriptions = append(descriptions, F.ToString("override-gateway=", r.OverrideGateway.String())) } if r.NetworkStrategy != nil { descriptions = append(descriptions, F.ToString("network-strategy=", r.NetworkStrategy)) diff --git a/route/rule/rule_default.go b/route/rule/rule_default.go index 1eef862d..b921c8b2 100644 --- a/route/rule/rule_default.go +++ b/route/rule/rule_default.go @@ -186,16 +186,6 @@ func NewDefaultRule(ctx context.Context, logger log.ContextLogger, options optio rule.destinationPortItems = append(rule.destinationPortItems, item) rule.allItems = append(rule.allItems, item) } - if len(options.TunnelSource) > 0 { - item := NewTunnelSourceItem(options.TunnelSource) - rule.items = append(rule.items, item) - rule.allItems = append(rule.allItems, item) - } - if len(options.TunnelDestination) > 0 { - item := NewTunnelDestinationItem(options.TunnelDestination) - rule.items = append(rule.items, item) - rule.allItems = append(rule.allItems, item) - } if len(options.ProcessName) > 0 { item := NewProcessItem(options.ProcessName) rule.items = append(rule.items, item) diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index dad49503..04f0f236 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -182,16 +182,6 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op rule.destinationPortItems = append(rule.destinationPortItems, item) rule.allItems = append(rule.allItems, item) } - if len(options.TunnelSource) > 0 { - item := NewTunnelSourceItem(options.TunnelSource) - rule.items = append(rule.items, item) - rule.allItems = append(rule.allItems, item) - } - if len(options.TunnelDestination) > 0 { - item := NewTunnelDestinationItem(options.TunnelDestination) - rule.items = append(rule.items, item) - rule.allItems = append(rule.allItems, item) - } if len(options.ProcessName) > 0 { item := NewProcessItem(options.ProcessName) rule.items = append(rule.items, item) diff --git a/route/rule/rule_headless.go b/route/rule/rule_headless.go index f11d1126..c5146318 100644 --- a/route/rule/rule_headless.go +++ b/route/rule/rule_headless.go @@ -130,16 +130,6 @@ func NewDefaultHeadlessRule(ctx context.Context, options option.DefaultHeadlessR rule.destinationPortItems = append(rule.destinationPortItems, item) rule.allItems = append(rule.allItems, item) } - if len(options.TunnelSource) > 0 { - item := NewTunnelSourceItem(options.TunnelSource) - rule.items = append(rule.items, item) - rule.allItems = append(rule.allItems, item) - } - if len(options.TunnelDestination) > 0 { - item := NewTunnelDestinationItem(options.TunnelDestination) - rule.items = append(rule.items, item) - rule.allItems = append(rule.allItems, item) - } if len(options.ProcessName) > 0 { item := NewProcessItem(options.ProcessName) rule.items = append(rule.items, item) diff --git a/route/rule/rule_item_tunnel_destination.go b/route/rule/rule_item_tunnel_destination.go deleted file mode 100644 index 34f711d6..00000000 --- a/route/rule/rule_item_tunnel_destination.go +++ /dev/null @@ -1,35 +0,0 @@ -package rule - -import ( - "strings" - - "github.com/sagernet/sing-box/adapter" - F "github.com/sagernet/sing/common/format" -) - -var _ RuleItem = (*TunnelDestinationItem)(nil) - -type TunnelDestinationItem struct { - destinations []string - destinationMap map[string]bool -} - -func NewTunnelDestinationItem(destinations []string) *TunnelDestinationItem { - rule := &TunnelDestinationItem{destinations, make(map[string]bool)} - for _, destination := range destinations { - rule.destinationMap[destination] = true - } - return rule -} - -func (r *TunnelDestinationItem) Match(metadata *adapter.InboundContext) bool { - return r.destinationMap[metadata.TunnelDestination] -} - -func (r *TunnelDestinationItem) String() string { - if len(r.destinations) == 1 { - return F.ToString("tunnel_destination=", r.destinations[0]) - } else { - return F.ToString("tunnel_destination=[", strings.Join(r.destinations, " "), "]") - } -} diff --git a/route/rule/rule_item_tunnel_source.go b/route/rule/rule_item_tunnel_source.go deleted file mode 100644 index 6a2f01cb..00000000 --- a/route/rule/rule_item_tunnel_source.go +++ /dev/null @@ -1,35 +0,0 @@ -package rule - -import ( - "strings" - - "github.com/sagernet/sing-box/adapter" - F "github.com/sagernet/sing/common/format" -) - -var _ RuleItem = (*TunnelSourceItem)(nil) - -type TunnelSourceItem struct { - sources []string - sourceMap map[string]bool -} - -func NewTunnelSourceItem(sources []string) *TunnelSourceItem { - rule := &TunnelSourceItem{sources, make(map[string]bool)} - for _, source := range sources { - rule.sourceMap[source] = true - } - return rule -} - -func (r *TunnelSourceItem) Match(metadata *adapter.InboundContext) bool { - return r.sourceMap[metadata.TunnelSource] -} - -func (r *TunnelSourceItem) String() string { - if len(r.sources) == 1 { - return F.ToString("tunnel_source=", r.sources[0]) - } else { - return F.ToString("tunnel_source=[", strings.Join(r.sources, " "), "]") - } -} 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..6bc36767 --- /dev/null +++ b/service/admin_panel/tables/user.go @@ -0,0 +1,288 @@ +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: "MTProxy", Value: "mtproxy"}, + {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: "MTProxy", Value: "mtproxy"}, + {Text: "Trojan", Value: "trojan"}, + {Text: "TUIC", Value: "tuic"}, + {Text: "VLESS", Value: "vless"}, + {Text: "VMess", Value: "vmess"}, + }). + FieldOnChooseOptionsHide([]string{""}, "inbound"). + FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "mtproxy", "shadowsocks", "trojan", "tuic"}, "uuid"). + FieldOnChooseOptionsHide([]string{"", "mtproxy", "vless", "vmess"}, "password"). + FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "shadowsocks", "trojan", "tuic", "vless", "vmess"}, "secret"). + FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "mtproxy", "shadowsocks", "trojan", "tuic", "vmess"}, "flow"). + FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "mtproxy", "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("Secret", "secret", 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"), + Secret: values.Get("secret"), + 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"), + Secret: values.Get("secret"), + 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..c8988c73 --- /dev/null +++ b/service/manager/constant/dto.go @@ -0,0 +1,168 @@ +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"` + Secret string `json:"secret" 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 mtproxy trojan tuic vless vmess"` + Inbound string `json:"inbound" validate:"required"` + UUID string `json:"uuid" validate:"omitempty,uuid4"` + Password string `json:"password" validate:"omitempty"` + Secret string `json:"secret" 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"` + Secret string `json:"secret" 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"` + Secret string `json:"secret" 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..2c08baaa --- /dev/null +++ b/service/manager/repository/postgresql/migration.go @@ -0,0 +1,133 @@ +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, + secret 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..c75eff8e --- /dev/null +++ b/service/manager/repository/postgresql/repository.go @@ -0,0 +1,1364 @@ +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, + secret, + flow, + alter_id, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING + id, + username, + type, + inbound, + uuid, + password, + secret, + flow, + alter_id, + created_at, + updated_at + `, + user.Username, + user.Type, + user.Inbound, + user.UUID, + user.Password, + user.Secret, + user.Flow, + user.AlterID, + now, + now, + ).Scan( + &u.ID, + &u.Username, + &u.Type, + &u.Inbound, + &u.UUID, + &u.Password, + &u.Secret, + &u.Flow, + &u.AlterID, + &u.CreatedAt, + &u.UpdatedAt, + ) + if err != nil { + return u, err + } + 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", + "secret", + "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.Secret, + &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, + secret, + 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.Secret, + &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, + secret = $3, + flow = $4, + alter_id = $5, + updated_at = $6 + WHERE id = $7 + 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, + secret, + flow, + alter_id, + created_at, + updated_at + `, + user.UUID, + user.Password, + user.Secret, + user.Flow, + user.AlterID, + time.Now(), + id, + ).Scan( + &u.ID, + &u.SquadIDs, + &u.Username, + &u.Type, + &u.Inbound, + &u.UUID, + &u.Password, + &u.Secret, + &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, + secret, + flow, + alter_id, + created_at, + updated_at + `, id).Scan( + &u.ID, + &u.SquadIDs, + &u.Username, + &u.Type, + &u.Inbound, + &u.UUID, + &u.Password, + &u.Secret, + &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..a8cb3408 --- /dev/null +++ b/service/manager/service.go @@ -0,0 +1,602 @@ +//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/v2" + "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[string, struct{}] + + 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", "") + } + case "mtproxy": + if user.Secret == "" { + sl.ReportError(user.Secret, "secret", "Secret", "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[string, struct{}]), + 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[string, struct{}]) + 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[string, struct{}](time.Second*30, time.Second) + lock.OnEvicted(func(_ string, _ struct{}) { + 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(), 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, 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/mtproxy.go b/service/node/inbound/mtproxy.go new file mode 100644 index 00000000..7aa24c41 --- /dev/null +++ b/service/node/inbound/mtproxy.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/mtproxy" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" +) + +type MTProxyManager struct { + access sync.Mutex + inbounds map[string]*MTProxyUserManager +} + +func NewMTProxyManager() *MTProxyManager { + return &MTProxyManager{ + inbounds: make(map[string]*MTProxyUserManager), + } +} + +func (m *MTProxyManager) AddUserManager(inbound adapter.Inbound) error { + m.access.Lock() + defer m.access.Unlock() + m.inbounds[inbound.Tag()] = &MTProxyUserManager{ + inbound: inbound.(*mtproxy.Inbound), + usersMap: make(map[string]option.MTProxyUser), + } + return nil +} + +func (m *MTProxyManager) GetUserManager(tag string) (constant.UserManager, bool) { + m.access.Lock() + defer m.access.Unlock() + inbound, ok := m.inbounds[tag] + return inbound, ok +} + +func (m *MTProxyManager) 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 MTProxyUserManager struct { + inbound *mtproxy.Inbound + usersMap map[string]option.MTProxyUser + + mtx sync.Mutex +} + +func (i *MTProxyUserManager) postUpdate() { + users := make([]option.MTProxyUser, 0, len(i.usersMap)) + for _, user := range i.usersMap { + users = append(users, user) + } + i.inbound.UpdateUsers(users) +} + +func (i *MTProxyUserManager) UpdateUser(user CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + i.usersMap[user.Username] = option.MTProxyUser{Name: user.Username, Secret: user.Secret} + i.postUpdate() +} + +func (i *MTProxyUserManager) UpdateUsers(users []CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.usersMap) + for _, user := range users { + i.usersMap[user.Username] = option.MTProxyUser{Name: user.Username, Secret: user.Secret} + } + i.postUpdate() +} + +func (i *MTProxyUserManager) 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..9528d9c3 --- /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, logger, 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/masque/adapter.go b/transport/masque/adapter.go new file mode 100644 index 00000000..f72748f7 --- /dev/null +++ b/transport/masque/adapter.go @@ -0,0 +1,82 @@ +package masque + +import ( + "sync" + + "github.com/sagernet/wireguard-go/tun" + "github.com/songgao/water" +) + +type NetstackAdapter struct { + dev tun.Device + tunnelBufPool sync.Pool + tunnelSizesPool sync.Pool +} + +func (n *NetstackAdapter) ReadPacket(buf []byte) (int, error) { + packetBufsPtr := n.tunnelBufPool.Get().(*[][]byte) + sizesPtr := n.tunnelSizesPool.Get().(*[]int) + + defer func() { + (*packetBufsPtr)[0] = nil + n.tunnelBufPool.Put(packetBufsPtr) + n.tunnelSizesPool.Put(sizesPtr) + }() + + (*packetBufsPtr)[0] = buf + (*sizesPtr)[0] = 0 + + _, err := n.dev.Read(*packetBufsPtr, *sizesPtr, 0) + if err != nil { + return 0, err + } + + return (*sizesPtr)[0], nil +} + +func (n *NetstackAdapter) WritePacket(pkt []byte) error { + // Write expects a slice of packet buffers. + _, err := n.dev.Write([][]byte{pkt}, 0) + return err +} + +// NewNetstackAdapter creates a new NetstackAdapter. +func NewNetstackAdapter(dev tun.Device) TunnelDevice { + return &NetstackAdapter{ + dev: dev, + tunnelBufPool: sync.Pool{ + New: func() interface{} { + buf := make([][]byte, 1) + return &buf + }, + }, + tunnelSizesPool: sync.Pool{ + New: func() interface{} { + sizes := make([]int, 1) + return &sizes + }, + }, + } +} + +type WaterAdapter struct { + iface *water.Interface +} + +func (w *WaterAdapter) ReadPacket(buf []byte) (int, error) { + n, err := w.iface.Read(buf) + if err != nil { + return 0, err + } + + return n, nil +} + +func (w *WaterAdapter) WritePacket(pkt []byte) error { + _, err := w.iface.Write(pkt) + return err +} + +func NewWaterAdapter(iface *water.Interface) TunnelDevice { + return &WaterAdapter{iface: iface} +} diff --git a/transport/masque/buffer.go b/transport/masque/buffer.go new file mode 100644 index 00000000..267f494f --- /dev/null +++ b/transport/masque/buffer.go @@ -0,0 +1,34 @@ +package masque + +import "sync" + +type NetBuffer struct { + capacity uint32 + buf sync.Pool +} + +func (n *NetBuffer) Get() []byte { + return *(n.buf.Get().(*[]byte)) +} + +func (n *NetBuffer) Put(buf []byte) { + if cap(buf) != int(n.capacity) { + return + } + n.buf.Put(&buf) +} + +func NewNetBuffer(capacity uint32) *NetBuffer { + if capacity == 0 { + panic("capacity must be greater than 0") + } + return &NetBuffer{ + capacity: capacity, + buf: sync.Pool{ + New: func() interface{} { + b := make([]byte, capacity) + return &b + }, + }, + } +} diff --git a/transport/masque/device.go b/transport/masque/device.go new file mode 100644 index 00000000..d6f71597 --- /dev/null +++ b/transport/masque/device.go @@ -0,0 +1,33 @@ +package masque + +import ( + "context" + "net/netip" + "time" + + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" + wgTun "github.com/sagernet/wireguard-go/tun" +) + +type Device interface { + wgTun.Device + N.Dialer + Start() error +} + +type DeviceOptions struct { + Context context.Context + Logger logger.ContextLogger + Handler tun.Handler + UDPTimeout time.Duration + CreateDialer func(interfaceName string) N.Dialer + Name string + MTU uint32 + Address []netip.Prefix +} + +func NewDevice(options DeviceOptions) (Device, error) { + return newStackDevice(options) +} diff --git a/transport/masque/device_stack.go b/transport/masque/device_stack.go new file mode 100644 index 00000000..a25115c0 --- /dev/null +++ b/transport/masque/device_stack.go @@ -0,0 +1,307 @@ +package masque + +import ( + "context" + "net" + "net/netip" + "os" + + "github.com/sagernet/gvisor/pkg/buffer" + "github.com/sagernet/gvisor/pkg/tcpip" + "github.com/sagernet/gvisor/pkg/tcpip/adapters/gonet" + "github.com/sagernet/gvisor/pkg/tcpip/header" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv4" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv6" + "github.com/sagernet/gvisor/pkg/tcpip/stack" + "github.com/sagernet/gvisor/pkg/tcpip/transport/icmp" + "github.com/sagernet/gvisor/pkg/tcpip/transport/tcp" + "github.com/sagernet/gvisor/pkg/tcpip/transport/udp" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/transport/wireguard" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + wgTun "github.com/sagernet/wireguard-go/tun" +) + +type stackDevice struct { + ctx context.Context + logger log.ContextLogger + stack *stack.Stack + mtu uint32 + events chan wgTun.Event + wgTun.Device + outbound chan *stack.PacketBuffer + packetOutbound chan *buf.Buffer + done chan struct{} + dispatcher stack.NetworkDispatcher + inet4Address netip.Addr + inet6Address netip.Addr +} + +func newStackDevice(options DeviceOptions) (*stackDevice, error) { + tunDevice := &stackDevice{ + ctx: options.Context, + logger: options.Logger, + mtu: options.MTU, + events: make(chan wgTun.Event, 1), + outbound: make(chan *stack.PacketBuffer, 256), + packetOutbound: make(chan *buf.Buffer, 256), + done: make(chan struct{}), + } + ipStack, err := tun.NewGVisorStackWithOptions((*wireEndpoint)(tunDevice), stack.NICOptions{}, true) + if err != nil { + return nil, err + } + var ( + inet4Address netip.Addr + inet6Address netip.Addr + ) + for _, prefix := range options.Address { + addr := tun.AddressFromAddr(prefix.Addr()) + protoAddr := tcpip.ProtocolAddress{ + AddressWithPrefix: tcpip.AddressWithPrefix{ + Address: addr, + PrefixLen: prefix.Bits(), + }, + } + if prefix.Addr().Is4() { + inet4Address = prefix.Addr() + tunDevice.inet4Address = inet4Address + protoAddr.Protocol = ipv4.ProtocolNumber + } else { + inet6Address = prefix.Addr() + tunDevice.inet6Address = inet6Address + protoAddr.Protocol = ipv6.ProtocolNumber + } + gErr := ipStack.AddProtocolAddress(tun.DefaultNIC, protoAddr, stack.AddressProperties{}) + if gErr != nil { + return nil, E.New("parse local address ", protoAddr.AddressWithPrefix, ": ", gErr.String()) + } + } + tunDevice.stack = ipStack + if options.Handler != nil { + ipStack.SetTransportProtocolHandler(tcp.ProtocolNumber, tun.NewTCPForwarder(options.Context, ipStack, options.Handler).HandlePacket) + ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, tun.NewUDPForwarder(options.Context, ipStack, options.Handler, options.UDPTimeout).HandlePacket) + icmpForwarder := tun.NewICMPForwarder(options.Context, ipStack, options.Handler, options.UDPTimeout) + ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket) + ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket) + } + return tunDevice, nil +} + +func (w *stackDevice) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + addr := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + Port: destination.Port, + Addr: tun.AddressFromAddr(destination.Addr), + } + bind := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + } + var networkProtocol tcpip.NetworkProtocolNumber + if destination.IsIPv4() { + if !w.inet4Address.IsValid() { + return nil, E.New("missing IPv4 local address") + } + networkProtocol = header.IPv4ProtocolNumber + bind.Addr = tun.AddressFromAddr(w.inet4Address) + } else { + if !w.inet6Address.IsValid() { + return nil, E.New("missing IPv6 local address") + } + networkProtocol = header.IPv6ProtocolNumber + bind.Addr = tun.AddressFromAddr(w.inet6Address) + } + switch N.NetworkName(network) { + case N.NetworkTCP: + tcpConn, err := wireguard.DialTCPWithBind(ctx, w.stack, bind, addr, networkProtocol) + if err != nil { + return nil, err + } + return tcpConn, nil + case N.NetworkUDP: + udpConn, err := gonet.DialUDP(w.stack, &bind, &addr, networkProtocol) + if err != nil { + return nil, err + } + return udpConn, nil + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } +} + +func (w *stackDevice) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + bind := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + } + var networkProtocol tcpip.NetworkProtocolNumber + if destination.IsIPv4() { + networkProtocol = header.IPv4ProtocolNumber + bind.Addr = tun.AddressFromAddr(w.inet4Address) + } else { + networkProtocol = header.IPv6ProtocolNumber + bind.Addr = tun.AddressFromAddr(w.inet4Address) + } + udpConn, err := gonet.DialUDP(w.stack, &bind, nil, networkProtocol) + if err != nil { + return nil, err + } + return udpConn, nil +} + +func (w *stackDevice) Start() error { + w.events <- wgTun.EventUp + return nil +} + +func (w *stackDevice) File() *os.File { + return nil +} + +func (w *stackDevice) Read(bufs [][]byte, sizes []int, offset int) (count int, err error) { + select { + case packet, ok := <-w.outbound: + if !ok { + return 0, os.ErrClosed + } + defer packet.DecRef() + var copyN int + /*rangeIterate(packet.Data().AsRange(), func(view *buffer.View) { + copyN += copy(bufs[0][offset+copyN:], view.AsSlice()) + })*/ + for _, view := range packet.AsSlices() { + copyN += copy(bufs[0][offset+copyN:], view) + } + sizes[0] = copyN + return 1, nil + case packet := <-w.packetOutbound: + defer packet.Release() + sizes[0] = copy(bufs[0][offset:], packet.Bytes()) + return 1, nil + case <-w.done: + return 0, os.ErrClosed + } +} + +func (w *stackDevice) Write(bufs [][]byte, offset int) (count int, err error) { + for _, b := range bufs { + b = b[offset:] + if len(b) == 0 { + continue + } + var networkProtocol tcpip.NetworkProtocolNumber + switch header.IPVersion(b) { + case header.IPv4Version: + networkProtocol = header.IPv4ProtocolNumber + case header.IPv6Version: + networkProtocol = header.IPv6ProtocolNumber + } + packetBuffer := stack.NewPacketBuffer(stack.PacketBufferOptions{ + Payload: buffer.MakeWithData(b), + }) + w.dispatcher.DeliverNetworkPacket(networkProtocol, packetBuffer) + packetBuffer.DecRef() + count++ + } + return +} + +func (w *stackDevice) Flush() error { + return nil +} + +func (w *stackDevice) MTU() (int, error) { + return int(w.mtu), nil +} + +func (w *stackDevice) Name() (string, error) { + return "sing-box", nil +} + +func (w *stackDevice) Events() <-chan wgTun.Event { + return w.events +} + +func (w *stackDevice) Close() error { + close(w.done) + close(w.events) + w.stack.Close() + for _, endpoint := range w.stack.CleanupEndpoints() { + endpoint.Abort() + } + w.stack.Wait() + return nil +} + +func (w *stackDevice) BatchSize() int { + return 1 +} + +var _ stack.LinkEndpoint = (*wireEndpoint)(nil) + +type wireEndpoint stackDevice + +func (ep *wireEndpoint) MTU() uint32 { + return ep.mtu +} + +func (ep *wireEndpoint) SetMTU(mtu uint32) { +} + +func (ep *wireEndpoint) MaxHeaderLength() uint16 { + return 0 +} + +func (ep *wireEndpoint) LinkAddress() tcpip.LinkAddress { + return "" +} + +func (ep *wireEndpoint) SetLinkAddress(addr tcpip.LinkAddress) { +} + +func (ep *wireEndpoint) Capabilities() stack.LinkEndpointCapabilities { + return stack.CapabilityRXChecksumOffload +} + +func (ep *wireEndpoint) Attach(dispatcher stack.NetworkDispatcher) { + ep.dispatcher = dispatcher +} + +func (ep *wireEndpoint) IsAttached() bool { + return ep.dispatcher != nil +} + +func (ep *wireEndpoint) Wait() { +} + +func (ep *wireEndpoint) ARPHardwareType() header.ARPHardwareType { + return header.ARPHardwareNone +} + +func (ep *wireEndpoint) AddHeader(buffer *stack.PacketBuffer) { +} + +func (ep *wireEndpoint) ParseHeader(ptr *stack.PacketBuffer) bool { + return true +} + +func (ep *wireEndpoint) WritePackets(list stack.PacketBufferList) (int, tcpip.Error) { + for _, packetBuffer := range list.AsSlice() { + packetBuffer.IncRef() + select { + case <-ep.done: + return 0, &tcpip.ErrClosedForSend{} + case ep.outbound <- packetBuffer: + } + } + return list.Len(), nil +} + +func (ep *wireEndpoint) Close() { +} + +func (ep *wireEndpoint) SetOnCloseAction(f func()) { +} diff --git a/transport/masque/masque.go b/transport/masque/masque.go new file mode 100644 index 00000000..62b90fd7 --- /dev/null +++ b/transport/masque/masque.go @@ -0,0 +1,166 @@ +package masque + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + + connectip "github.com/Diniboy1123/connect-ip-go" + "github.com/sagernet/quic-go" + "github.com/sagernet/quic-go/http3" + qtls "github.com/sagernet/sing-quic" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + aTLS "github.com/sagernet/sing/common/tls" + "github.com/yosida95/uritemplate/v3" + "golang.org/x/net/http2" +) + +type ( + DialContext func(ctx context.Context, network, address string) (net.Conn, error) + ListenPacket func(network string, address string) (net.PacketConn, error) +) + +func ConnectTunnel(ctx context.Context, dialer N.Dialer, tlsConfig aTLS.Config, quicConfig *quic.Config, connectUri string, endpoint net.Addr, useHTTP2 bool) (net.PacketConn, *http3.Transport, *connectip.Conn, *http.Response, error) { + template := uritemplate.MustNew(connectUri) + additionalHeaders := http.Header{ + "User-Agent": []string{""}, + } + if useHTTP2 { + h2Endpoint, ok := endpoint.(*net.TCPAddr) + if !ok || h2Endpoint == nil { + return nil, nil, nil, nil, errors.New("missing HTTP/2 TCP endpoint") + } + h2Headers := additionalHeaders.Clone() + h2Headers.Set("cf-connect-proto", "cf-connect-ip") + h2Headers.Set("pq-enabled", "false") + h2Client, err := newHTTP2Client(dialer, tlsConfig, h2Endpoint, connectUri) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to create HTTP/2 client: %w", err) + } + ipConn, rsp, err := connectip.DialH2(ctx, h2Client, template, h2Headers) + if err != nil { + if strings.Contains(err.Error(), "tls: access denied") { + return nil, nil, nil, nil, errors.New("login failed! Please double-check if your tls key and cert is enrolled in the Cloudflare Access service") + } + return nil, nil, nil, nil, fmt.Errorf("failed to dial connect-ip over HTTP/2: %w", err) + } + return nil, nil, ipConn, rsp, nil + } + quicEndpoint, ok := endpoint.(*net.UDPAddr) + if !ok || quicEndpoint == nil { + return nil, nil, nil, nil, errors.New("missing HTTP/3 UDP endpoint") + } + udpConn, err := dialer.ListenPacket(ctx, M.SocksaddrFromNetIP(quicEndpoint.AddrPort())) + if err != nil { + return nil, nil, nil, nil, err + } + conn, err := qtls.Dial( + ctx, + udpConn, + quicEndpoint, + tlsConfig, + quicConfig, + ) + if err != nil { + return nil, nil, nil, nil, err + } + tr := &http3.Transport{ + EnableDatagrams: true, + AdditionalSettings: map[uint64]uint64{ + // official client still sends this out as well, even though + // it's deprecated, see https://datatracker.ietf.org/doc/draft-ietf-masque-h3-datagram/00/ + // SETTINGS_H3_DATAGRAM_00 = 0x0000000000000276 + // https://github.com/cloudflare/quiche/blob/7c66757dbc55b8d0c3653d4b345c6785a181f0b7/quiche/src/h3/frame.rs#L46 + 0x276: 1, + }, + DisableCompression: true, + } + hconn := tr.NewClientConn(conn) + ipConn, rsp, err := connectip.Dial(ctx, hconn, template, "cf-connect-ip", additionalHeaders, true) + if err != nil { + if err.Error() == "CRYPTO_ERROR 0x131 (remote): tls: access denied" { + return udpConn, nil, nil, nil, errors.New("login failed! Please double-check if your tls key and cert is enrolled in the Cloudflare Access service") + } + return udpConn, nil, nil, nil, fmt.Errorf("failed to dial connect-ip: %w", err) + } + err = ipConn.AdvertiseRoute(ctx, []connectip.IPRoute{ + { + IPProtocol: 0, + StartIP: netip.AddrFrom4([4]byte{}), + EndIP: netip.AddrFrom4([4]byte{255, 255, 255, 255}), + }, + { + IPProtocol: 0, + StartIP: netip.AddrFrom16([16]byte{}), + EndIP: netip.AddrFrom16([16]byte{ + 255, 255, 255, 255, + 255, 255, 255, 255, + 255, 255, 255, 255, + 255, 255, 255, 255, + }), + }, + }) + if err != nil { + return udpConn, nil, nil, nil, err + } + return udpConn, tr, ipConn, rsp, nil +} + +func newHTTP2Client(dialer N.Dialer, baseTLSConfig aTLS.Config, endpoint *net.TCPAddr, connectURI string) (*http.Client, error) { + if endpoint == nil { + return nil, errors.New("missing HTTP/2 endpoint") + } + tlsConfig := baseTLSConfig.Clone() + tlsConfig.SetNextProtos([]string{"h2"}) + return &http.Client{ + Transport: &http2.Transport{ + DialTLSContext: func(ctx context.Context, network, _ string, _ *tls.Config) (net.Conn, error) { + conn, err := dialer.DialContext(ctx, network, M.SocksaddrFromNetIP(endpoint.AddrPort())) + if err != nil { + return nil, err + } + tlsConn, err := tlsConfig.Client(conn) + if err != nil { + return nil, err + } + if err := tlsConn.HandshakeContext(ctx); err != nil { + _ = conn.Close() + return nil, err + } + return tlsConn, nil + }, + }, + }, nil +} + +func authorityWithDefaultPort(u *url.URL, defaultPort string) string { + if u == nil { + return "" + } + + host := u.Hostname() + if host == "" { + return u.Host + } + + port := u.Port() + if port == "" { + port = defaultPort + } + + return net.JoinHostPort(host, port) +} + +func proxyDefaultPort(u *url.URL) string { + if u != nil && u.Scheme == "https" { + return "443" + } + return "80" +} diff --git a/transport/masque/options.go b/transport/masque/options.go new file mode 100644 index 00000000..b2722436 --- /dev/null +++ b/transport/masque/options.go @@ -0,0 +1,24 @@ +package masque + +import ( + "net" + "net/netip" + "time" + + tun "github.com/sagernet/sing-tun" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/tls" +) + +type TunnelOptions struct { + Handler tun.Handler + Dialer N.Dialer + Address []netip.Prefix + Endpoint net.Addr + TLSConfig tls.Config + UseHTTP2 bool + UDPTimeout time.Duration + UDPKeepalivePeriod time.Duration + UDPInitialPacketSize uint16 + ReconnectDelay time.Duration +} diff --git a/transport/masque/tunnel.go b/transport/masque/tunnel.go new file mode 100644 index 00000000..c5f65443 --- /dev/null +++ b/transport/masque/tunnel.go @@ -0,0 +1,200 @@ +package masque + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "time" + + connectip "github.com/Diniboy1123/connect-ip-go" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" +) + +type TunnelDevice interface { + ReadPacket(buf []byte) (int, error) + WritePacket(pkt []byte) error +} + +type Tunnel struct { + ctx context.Context + logger logger.ContextLogger + options TunnelOptions + tunDevice Device + tunnelDevice TunnelDevice +} + +func NewTunnel(ctx context.Context, logger logger.ContextLogger, options TunnelOptions) (*Tunnel, error) { + deviceOptions := DeviceOptions{ + Context: ctx, + Logger: logger, + Handler: options.Handler, + UDPTimeout: options.UDPTimeout, + MTU: 1280, + Address: options.Address, + } + tunDevice, err := NewDevice(deviceOptions) + if err != nil { + return nil, E.Cause(err, "create MASQUE device") + } + return &Tunnel{ + ctx: ctx, + logger: logger, + options: options, + tunDevice: tunDevice, + tunnelDevice: NewNetstackAdapter(tunDevice), + }, nil +} + +func (e *Tunnel) Start(resolve bool) error { + if resolve { + err := e.tunDevice.Start() + if err != nil { + return err + } + go e.MaintainTunnel() + } + return nil +} + +func (e *Tunnel) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if !destination.Addr.IsValid() { + return nil, E.Cause(os.ErrInvalid, "invalid non-IP destination") + } + return e.tunDevice.DialContext(ctx, network, destination) +} + +func (e *Tunnel) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if !destination.Addr.IsValid() { + return nil, E.Cause(os.ErrInvalid, "invalid non-IP destination") + } + return e.tunDevice.ListenPacket(ctx, destination) +} + +func (e *Tunnel) Close() error { + return e.tunDevice.Close() +} + +func (e *Tunnel) MaintainTunnel() { + packetBufferPool := NewNetBuffer(1280) + timer := time.NewTimer(0) + defer timer.Stop() + for { + select { + case <-e.ctx.Done(): + return + default: + } + e.logger.InfoContext(e.ctx, fmt.Errorf("Establishing MASQUE connection to %s", e.options.Endpoint)) + udpConn, tr, ipConn, rsp, err := ConnectTunnel( + e.ctx, + e.options.Dialer, + e.options.TLSConfig, + DefaultQuicConfig(e.options.UDPKeepalivePeriod, e.options.UDPInitialPacketSize), + "https://cloudflareaccess.com", + e.options.Endpoint, + e.options.UseHTTP2, + ) + if err != nil { + e.logger.InfoContext(e.ctx, fmt.Errorf("Failed to connect tunnel: %v", err)) + timer.Reset(e.options.ReconnectDelay) + select { + case <-e.ctx.Done(): + return + case <-timer.C: + } + continue + } + if rsp.StatusCode != 200 { + e.logger.InfoContext(e.ctx, fmt.Errorf("Tunnel connection failed: %s", rsp.Status)) + ipConn.Close() + if udpConn != nil { + udpConn.Close() + } + if tr != nil { + tr.Close() + } + timer.Reset(e.options.ReconnectDelay) + select { + case <-e.ctx.Done(): + return + case <-timer.C: + } + continue + } + e.logger.InfoContext(e.ctx, "Connected to MASQUE server") + errChan := make(chan error, 2) + go func() { + for { + buf := packetBufferPool.Get() + n, err := e.tunnelDevice.ReadPacket(buf) + if err != nil { + packetBufferPool.Put(buf) + errChan <- fmt.Errorf("failed to read from TUN device: %w", err) + return + } + icmp, err := ipConn.WritePacket(buf[:n]) + if err != nil { + packetBufferPool.Put(buf) + if errors.As(err, new(*connectip.CloseError)) { + errChan <- fmt.Errorf("connection closed while writing to IP connection: %w", err) + return + } + e.logger.InfoContext(e.ctx, fmt.Errorf("Error writing to IP connection: %v, continuing...", err)) + continue + } + packetBufferPool.Put(buf) + if len(icmp) > 0 { + if err := e.tunnelDevice.WritePacket(icmp); err != nil { + if errors.As(err, new(*connectip.CloseError)) { + errChan <- fmt.Errorf("connection closed while writing ICMP to TUN device: %w", err) + return + } + e.logger.InfoContext(e.ctx, fmt.Errorf("Error writing ICMP to TUN device: %v, continuing...", err)) + } + } + } + }() + go func() { + buf := packetBufferPool.Get() + defer packetBufferPool.Put(buf) + for { + n, err := ipConn.ReadPacket(buf, true) + if err != nil { + if e.options.UseHTTP2 { + errChan <- fmt.Errorf("connection closed while reading from IP connection: %w", err) + return + } + if errors.As(err, new(*connectip.CloseError)) { + errChan <- fmt.Errorf("connection closed while reading from IP connection: %w", err) + return + } + e.logger.InfoContext(e.ctx, fmt.Errorf("Error reading from IP connection: %v, continuing...", err)) + continue + } + if err := e.tunnelDevice.WritePacket(buf[:n]); err != nil { + errChan <- fmt.Errorf("failed to write to TUN device: %w", err) + return + } + } + }() + err = <-errChan + e.logger.InfoContext(e.ctx, fmt.Errorf("Tunnel connection lost: %v. Reconnecting...", err)) + ipConn.Close() + if udpConn != nil { + udpConn.Close() + } + if tr != nil { + tr.Close() + } + timer.Reset(e.options.ReconnectDelay) + select { + case <-e.ctx.Done(): + return + case <-timer.C: + } + } +} diff --git a/transport/masque/utils.go b/transport/masque/utils.go new file mode 100644 index 00000000..b99b4459 --- /dev/null +++ b/transport/masque/utils.go @@ -0,0 +1,326 @@ +package masque + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "errors" + "log" + "math/big" + "net" + "strconv" + "strings" + "time" + + "github.com/sagernet/quic-go" +) + +// PortMapping represents a network port forwarding rule. +type PortMapping struct { + BindAddress string // The address to bind the local port. + LocalPort int // The local port number. + RemoteIP string // The remote destination IP address. + RemotePort int // The remote destination port number. +} + +// GenerateRandomAndroidSerial generates a random 8-byte Android-like device identifier +// and returns it as a hexadecimal string. +// +// Returns: +// - string: A randomly generated 16-character hexadecimal serial number. +// - error: An error if random data generation fails. +func GenerateRandomAndroidSerial() (string, error) { + serial := make([]byte, 8) + if _, err := rand.Read(serial); err != nil { + return "", err + } + return hex.EncodeToString(serial), nil +} + +// GenerateRandomWgPubkey generates a random 32-byte WireGuard like public key +// and returns it as a base64-encoded string. +// +// Returns: +// - string: A randomly generated WireGuard like public key in base64 format. +// - error: An error if random data generation fails. +func GenerateRandomWgPubkey() (string, error) { + publicKey := make([]byte, 32) + if _, err := rand.Read(publicKey); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(publicKey), nil +} + +// TimeAsCfString formats a given time.Time into a Cloudflare-compatible string format. +// +// The format follows the standard: "YYYY-MM-DDTHH:MM:SS.sss-07:00". +// +// Parameters: +// - t: time.Time to format. +// +// Returns: +// - string: The formatted time string. +func TimeAsCfString(t time.Time) string { + return t.Format("2006-01-02T15:04:05.000-07:00") +} + +// GenerateEcKeyPair generates a new ECDSA key pair using the P-256 curve. +// +// Returns: +// - []byte: The marshalled private key in ASN.1 DER format. +// - []byte: The marshalled public key in PKIX format. +// - error: An error if key generation or marshalling fails. +func GenerateEcKeyPair() ([]byte, []byte, error) { + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, err + } + + marshalledPrivKey, err := x509.MarshalECPrivateKey(privKey) + if err != nil { + return nil, nil, err + } + + marshalledPubKey, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) + if err != nil { + return nil, nil, err + } + + return marshalledPrivKey, marshalledPubKey, nil +} + +// GenerateCert creates a self-signed certificate using the provided ECDSA private and public keys. +// +// The certificate is valid for 24 hours. +// +// Parameters: +// - privKey: *ecdsa.PrivateKey - The private key to sign the certificate. +// - pubKey: *ecdsa.PublicKey - The public key to include in the certificate. +// +// Returns: +// - [][]byte: A slice containing the certificate in DER format. +// - error: An error if certificate generation fails. +func GenerateCert(privKey *ecdsa.PrivateKey, pubKey *ecdsa.PublicKey) ([][]byte, error) { + cert, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + SerialNumber: big.NewInt(0), + NotBefore: time.Now(), + NotAfter: time.Now().Add(1 * 24 * time.Hour), + }, &x509.Certificate{}, &privKey.PublicKey, privKey) + if err != nil { + return nil, err + } + + return [][]byte{cert}, nil +} + +// DefaultQuicConfig returns a MASQUE-compatible default QUIC configuration. +// +// When initialPacketSize is 0, Path MTU Discovery remains enabled. +// +// Parameters: +// - keepalivePeriod: time.Duration - The duration for sending QUIC keep-alive packets. +// - initialPacketSize: uint16 - The custom initial size of QUIC packets (0 = auto with PMTU discovery). +// +// Returns: +// - *quic.Config: A pointer to a configured QUIC configuration object. +func DefaultQuicConfig(keepalivePeriod time.Duration, initialPacketSize uint16) *quic.Config { + cfg := &quic.Config{ + EnableDatagrams: true, + KeepAlivePeriod: keepalivePeriod, + } + + if initialPacketSize > 0 { + cfg.InitialPacketSize = initialPacketSize + cfg.DisablePathMTUDiscovery = true + } + + return cfg +} + +// parsePortMapping is an internal helper function that parses a port mapping string into its components. +// +// It handles IPv6 addresses enclosed in brackets and various format edge cases. +// +// Parameters: +// - port: string - The port mapping string. +// +// Returns: +// - string: The bind address. +// - int: The local port. +// - string: The remote hostname/IP. +// - int: The remote port. +// - error: An error if parsing fails. +func parsePortMapping(port string) (bindAddress string, localPort int, remoteHost string, remotePort int, err error) { + parts := strings.Split(port, ":") + + // Handle IPv6 addresses (which are enclosed in brackets) + if len(parts) >= 4 && strings.HasPrefix(parts[0], "[") && strings.Contains(parts[0], "]") { + bindAddress = parts[0] + parts = parts[1:] // Shift parts forward + } else if len(parts) == 3 { + bindAddress = "localhost" // Default to localhost + } else if len(parts) == 4 { + bindAddress = parts[0] + parts = parts[1:] // Shift forward + } else { + return "", 0, "", 0, errors.New("invalid port mapping format (expected format: [bind_address:]local_port:remote_host:remote_port)") + } + + // Parse local port + localPort, err = strconv.Atoi(parts[0]) + if err != nil || localPort <= 0 || localPort > 65535 { + return "", 0, "", 0, errors.New("invalid local port") + } + + // Validate remote host (allow both hostnames and IPs) + remoteHost = parts[1] + if net.ParseIP(remoteHost) == nil && !isValidHostname(remoteHost) { + return "", 0, "", 0, errors.New("invalid remote hostname/IP") + } + + // Parse remote port + remotePort, err = strconv.Atoi(parts[2]) + if err != nil || remotePort <= 0 || remotePort > 65535 { + return "", 0, "", 0, errors.New("invalid remote port") + } + + // If bindAddress is an IPv6 address, remove brackets for proper binding + if strings.HasPrefix(bindAddress, "[") && strings.HasSuffix(bindAddress, "]") { + bindAddress = strings.Trim(bindAddress, "[]") + } + + // Convert "localhost" or hostnames to actual addresses + if bindAddress == "*" { + bindAddress = "0.0.0.0" // Allow all interfaces + } + + // Validate bind address (support both IPs and hostnames) + bindAddress, err = resolveBindAddress(bindAddress) + if err != nil { + return "", 0, "", 0, errors.New("invalid local address: " + err.Error()) + } + + remoteHost, err = resolveBindAddress(remoteHost) + if err != nil { + return "", 0, "", 0, errors.New("invalid remote address: " + err.Error()) + } + + return bindAddress, localPort, remoteHost, remotePort, nil +} + +// ParsePortMapping parses a port mapping string into a structured PortMapping. +// +// The expected format is: `[bind_address:]local_port:remote_host:remote_port`. +// +// Parameters: +// - port: string - The port mapping string. +// +// Returns: +// - PortMapping: A structured representation of the parsed port mapping. +// - error: An error if the parsing fails. +func ParsePortMapping(port string) (PortMapping, error) { + bindAddress, localPort, remoteHost, remotePort, err := parsePortMapping(port) + if err != nil { + return PortMapping{}, err + } + + return PortMapping{ + BindAddress: bindAddress, + LocalPort: localPort, + RemoteIP: remoteHost, + RemotePort: remotePort, + }, nil +} + +// resolveBindAddress resolves a hostname or IP to its string representation. +// +// Parameters: +// - addr: string - The hostname or IP. +// +// Returns: +// - string: The resolved IP address. +// - error: An error if resolution fails. +func resolveBindAddress(addr string) (string, error) { + tcpAddr, err := net.ResolveTCPAddr("tcp", addr+":0") // Resolve the address + if err != nil { + return "", err + } + return tcpAddr.IP.String(), nil // Return resolved IP +} + +// isValidHostname checks if a given hostname is valid. +// Pretty ugly for now, needs to be refactored. +// +// Parameters: +// - hostname: string - The hostname to validate. +// +// Returns: +// - bool: True if valid, false otherwise. +func isValidHostname(hostname string) bool { + // Must contain at least one dot (.) unless it's "localhost" + if hostname == "localhost" { + return true + } + return strings.Contains(hostname, ".") +} + +// LoginToBase64 encodes a username and password into a base64-encoded string in "username:password" format. +// This is commonly used for HTTP Basic Authentication. +// +// Parameters: +// - username: string - The username to encode. +// - password: string - The password to encode. +// +// Returns: +// - string: The base64-encoded "username:password" string. +func LoginToBase64(username, password string) string { + return base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) +} + +// CheckIfname validates a network interface name according to the following rules: +// - Must not be empty. +// - Should not exceed 15 characters (warning if it does). +// - Should not contain non-ASCII characters (warning if it does). +// - Should not contain invalid characters: '/', whitespace, or control characters. +// +// Parameters: +// - name: string - The interface name to validate. +// +// Returns: +// - error: An error if the name is invalid, or nil if valid. +func CheckIfname(name string) error { + if name == "" { + return errors.New("interface name cannot be empty") + } + + if len(name) >= 16 { + log.Printf("Warning: interface name '%s' is longer than %d characters", name, 16-1) + } + + var invalidChar bool + var hasWhitespace bool + + for _, r := range name { + if r > 127 { + invalidChar = true + break + } + if r == '/' || r == ' ' || strings.ContainsRune("\t\n\v\f\r", r) { + hasWhitespace = true + break + } + } + + if invalidChar { + log.Printf("Warning: interface name contains non-ASCII character") + } + + if hasWhitespace { + return errors.New("interface name contains invalid character: '/' or whitespace") + } + + return nil +} diff --git a/transport/v2ray/transport.go b/transport/v2ray/transport.go index 27237f7c..8fab2247 100644 --- a/transport/v2ray/transport.go +++ b/transport/v2ray/transport.go @@ -10,6 +10,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" @@ -43,6 +44,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) } @@ -68,6 +71,8 @@ func NewClientTransport(ctx context.Context, logger log.ContextLogger, dialer N. return v2rayhttpupgrade.NewClient(ctx, dialer, serverAddr, options.HTTPUpgradeOptions, tlsConfig) case C.V2RayTransportTypeXHTTP: return xhttp.NewClient(ctx, logger, 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 282c7c23..d9e1f128 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) flusher := writer.(http.Flusher) @@ -143,7 +143,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { NewHTTPConn(request.Body, writer), 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 e7a5d019..da7e36d7 100644 --- a/transport/v2rayxhttp/server.go +++ b/transport/v2rayxhttp/server.go @@ -22,6 +22,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" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/logger" @@ -328,7 +329,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(): diff --git a/transport/wireguard/endpoint.go b/transport/wireguard/endpoint.go index ae12ec0b..b8010334 100644 --- a/transport/wireguard/endpoint.go +++ b/transport/wireguard/endpoint.go @@ -221,17 +221,17 @@ func (e *Endpoint) Start(resolve bool) error { if e.options.Amnezia.S4 > 0 { ipcConf += "\ns4=" + strconv.Itoa(e.options.Amnezia.S4) } - if e.options.Amnezia.H1 > 0 { - ipcConf += "\nh1=" + strconv.FormatUint(uint64(e.options.Amnezia.H1), 10) + if e.options.Amnezia.H1 != nil { + ipcConf += "\nh1=" + e.options.Amnezia.H1.String() } - if e.options.Amnezia.H2 > 0 { - ipcConf += "\nh2=" + strconv.FormatUint(uint64(e.options.Amnezia.H2), 10) + if e.options.Amnezia.H2 != nil { + ipcConf += "\nh2=" + e.options.Amnezia.H2.String() } - if e.options.Amnezia.H3 > 0 { - ipcConf += "\nh3=" + strconv.FormatUint(uint64(e.options.Amnezia.H3), 10) + if e.options.Amnezia.H3 != nil { + ipcConf += "\nh3=" + e.options.Amnezia.H3.String() } - if e.options.Amnezia.H4 > 0 { - ipcConf += "\nh4=" + strconv.FormatUint(uint64(e.options.Amnezia.H4), 10) + if e.options.Amnezia.H4 != nil { + ipcConf += "\nh4=" + e.options.Amnezia.H4.String() } if e.options.Amnezia.I1 != "" { ipcConf += "\ni1=" + e.options.Amnezia.I1 diff --git a/transport/wireguard/endpoint_options.go b/transport/wireguard/endpoint_options.go index a339b328..5cd6ec99 100644 --- a/transport/wireguard/endpoint_options.go +++ b/transport/wireguard/endpoint_options.go @@ -5,6 +5,7 @@ import ( "net/netip" "time" + Xbadoption "github.com/sagernet/sing-box/common/xray/json/badoption" tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" @@ -49,10 +50,10 @@ type AmneziaOptions struct { S2 int S3 int S4 int - H1 uint32 - H2 uint32 - H3 uint32 - H4 uint32 + H1 *Xbadoption.Range + H2 *Xbadoption.Range + H3 *Xbadoption.Range + H4 *Xbadoption.Range I1 string I2 string I3 string