From 474aa5b5d918da6b6be19b3276a180b8215cb8f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 15 Sep 2026 18:29:41 +0800 Subject: [PATCH 01/66] Fix crash on corrupted cache file --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 5f09cf8f..a49a36e3 100644 --- a/go.mod +++ b/go.mod @@ -34,7 +34,7 @@ require ( github.com/oschwald/maxminddb-golang v1.13.1 github.com/pkg/sftp v1.13.10 github.com/sagernet/asc-go v0.0.0-20260827085112-8f8226245b0d - github.com/sagernet/bbolt v0.0.0-20260823094646-e24805439c9c + github.com/sagernet/bbolt v0.0.0-20260915102804-500ee1e84832 github.com/sagernet/cors v1.2.1 github.com/sagernet/cronet-go v0.0.0-20260912104727-0d28acc44093 github.com/sagernet/cronet-go/all v0.0.0-20260912104727-0d28acc44093 diff --git a/go.sum b/go.sum index 669bcba5..7c6d6b9b 100644 --- a/go.sum +++ b/go.sum @@ -238,8 +238,8 @@ github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= github.com/sagernet/asc-go v0.0.0-20260827085112-8f8226245b0d h1:r3JBMjxwI8BPLprJpklmcgoPT3r7rJfUg4uuGWky48A= github.com/sagernet/asc-go v0.0.0-20260827085112-8f8226245b0d/go.mod h1:JULDuzTMn2gyZFcjpTVZP4/UuwAdbHJ0bum2RdjXojU= -github.com/sagernet/bbolt v0.0.0-20260823094646-e24805439c9c h1:JdEDkgiwsDxiZb4+dQYgVOU/lND+jZxFmN5y/q49EXk= -github.com/sagernet/bbolt v0.0.0-20260823094646-e24805439c9c/go.mod h1:7/qFPFH5fqK5XQeY8EA1Qpaly08Av77p38Zt50ieQww= +github.com/sagernet/bbolt v0.0.0-20260915102804-500ee1e84832 h1:KDXMGbzqLmDdKcld0T3wuMuqf1iezZq+DemgtZ/vhtQ= +github.com/sagernet/bbolt v0.0.0-20260915102804-500ee1e84832/go.mod h1:7/qFPFH5fqK5XQeY8EA1Qpaly08Av77p38Zt50ieQww= 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-20260912104727-0d28acc44093 h1:CsvsLVIli53L6nzgEpyRjjWnRRhTds4D3eh5sEnF5Sw= From 491813c36096e7cd6aafa667b3ef0c9d9724d0e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 16 Sep 2026 09:29:22 +0800 Subject: [PATCH 02/66] Bind network reset dispatch to manager lifecycle --- route/network.go | 160 +++++++++++++++++++++++++---------------------- 1 file changed, 86 insertions(+), 74 deletions(-) diff --git a/route/network.go b/route/network.go index 1ee735cc..ae950e44 100644 --- a/route/network.go +++ b/route/network.go @@ -25,6 +25,7 @@ import ( "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" "github.com/sagernet/sing/common/winpowrprof" + "github.com/sagernet/sing/common/x/list" "github.com/sagernet/sing/service" "github.com/sagernet/sing/service/pause" @@ -34,37 +35,39 @@ import ( var _ adapter.NetworkManager = (*NetworkManager)(nil) type NetworkManager struct { - ctx context.Context - logger logger.ContextLogger - router adapter.Router - interfaceFinder *control.DefaultInterfaceFinder - networkInterfaces common.TypedValue[[]adapter.NetworkInterface] - autoDetectInterface bool - defaultOptions adapter.NetworkOptions - autoRedirectOutputMark uint32 - networkMonitor tun.NetworkUpdateMonitor - interfaceMonitor tun.DefaultInterfaceMonitor - packageManager tun.PackageManager - powerListener winpowrprof.EventListener - pauseManager pause.Manager - platformInterface adapter.PlatformInterface - connectionManager adapter.ConnectionManager - endpoint adapter.EndpointManager - inbound adapter.InboundManager - outbound adapter.OutboundManager - needWIFIState bool - wifiMonitor settings.WIFIMonitor - wifiState adapter.WIFIState - networkEnvironment uint64 - stateAccess sync.RWMutex - environmentUpdateAccess sync.Mutex - environmentUpdateTimer *time.Timer - interfaceUpdateAccess sync.Mutex - interfaceUpdateCancel context.CancelFunc - interfaceUpdateRunAccess sync.Mutex - powerUpdateAccess sync.Mutex - powerUpdateCancel context.CancelFunc - started bool + ctx context.Context + logger logger.ContextLogger + router adapter.Router + interfaceFinder *control.DefaultInterfaceFinder + networkInterfaces common.TypedValue[[]adapter.NetworkInterface] + autoDetectInterface bool + defaultOptions adapter.NetworkOptions + autoRedirectOutputMark uint32 + networkMonitor tun.NetworkUpdateMonitor + interfaceMonitor tun.DefaultInterfaceMonitor + packageManager tun.PackageManager + powerListener winpowrprof.EventListener + pauseManager pause.Manager + platformInterface adapter.PlatformInterface + connectionManager adapter.ConnectionManager + endpoint adapter.EndpointManager + inbound adapter.InboundManager + outbound adapter.OutboundManager + needWIFIState bool + wifiMonitor settings.WIFIMonitor + wifiState adapter.WIFIState + networkEnvironment uint64 + stateAccess sync.RWMutex + environmentUpdateAccess sync.Mutex + environmentUpdateTimer *time.Timer + startedCtx context.Context + startedCancel context.CancelFunc + interfaceUpdateElement *list.Element[tun.DefaultInterfaceUpdateCallback] + interfaceUpdateAccess sync.Mutex + interfaceUpdateCancel context.CancelFunc + resetRunAccess sync.Mutex + powerUpdateAccess sync.Mutex + powerUpdateCancel context.CancelFunc } func NewNetworkManager(ctx context.Context, logger logger.ContextLogger, options option.RouteOptions, dnsOptions option.DNSOptions) (*NetworkManager, error) { @@ -134,13 +137,10 @@ func NewNetworkManager(ctx context.Context, logger logger.ContextLogger, options if err != nil { return nil, E.New("auto_detect_interface unsupported on current platform") } - interfaceMonitor.RegisterCallback(nm.notifyInterfaceUpdate) nm.interfaceMonitor = interfaceMonitor } } else { - interfaceMonitor := nm.platformInterface.CreateDefaultInterfaceMonitor(logger) - interfaceMonitor.RegisterCallback(nm.notifyInterfaceUpdate) - nm.interfaceMonitor = interfaceMonitor + nm.interfaceMonitor = nm.platformInterface.CreateDefaultInterfaceMonitor(logger) } return nm, nil } @@ -167,22 +167,6 @@ func (r *NetworkManager) Start(stage adapter.StartStage) error { } } case adapter.StartStateStart: - if runtime.GOOS == "windows" { - powerListener, err := winpowrprof.NewEventListener(r.notifyWindowsPowerEvent) - if err == nil { - r.powerListener = powerListener - } else { - r.logger.Warn("initialize power listener: ", err) - } - } - if r.powerListener != nil { - monitor.Start("start power listener") - err := r.powerListener.Start() - monitor.Finish() - if err != nil { - return E.Cause(err, "start power listener") - } - } if C.IsAndroid && r.platformInterface == nil { monitor.Start("initialize package manager") packageManager, err := tun.NewPackageManager(tun.PackageManagerOptions{ @@ -217,7 +201,30 @@ func (r *NetworkManager) Start(stage adapter.StartStage) error { } } } - r.started = true + r.startedCtx, r.startedCancel = context.WithCancel(r.ctx) + if runtime.GOOS == "windows" { + powerListener, err := winpowrprof.NewEventListener(r.notifyWindowsPowerEvent) + if err == nil { + r.powerListener = powerListener + } else { + r.logger.Warn("initialize power listener: ", err) + } + } + if r.powerListener != nil { + monitor.Start("start power listener") + err := r.powerListener.Start() + monitor.Finish() + if err != nil { + return E.Cause(err, "start power listener") + } + } + if r.interfaceMonitor != nil { + r.interfaceUpdateElement = r.interfaceMonitor.RegisterCallback(r.notifyInterfaceUpdate) + // Every monitor implementation has already delivered the initial state when Start returned: + // sing-tun checks routes synchronously, the Apple client blocks on the first NWPathMonitor update, + // and the Android client resolves the active network before setListener returns. + r.notifyInterfaceUpdate(r.interfaceMonitor.DefaultInterface(), 0) + } } return nil } @@ -235,12 +242,9 @@ func (r *NetworkManager) Initialize(ruleSets []adapter.RuleSet) { func (r *NetworkManager) Close() error { monitor := taskmonitor.New(r.logger, C.StopTimeout) var err error - if r.packageManager != nil { - monitor.Start("close package manager") - err = E.Append(err, r.packageManager.Close(), func(err error) error { - return E.Cause(err, "close package manager") - }) - monitor.Finish() + if r.interfaceUpdateElement != nil { + r.interfaceMonitor.UnregisterCallback(r.interfaceUpdateElement) + r.interfaceUpdateElement = nil } if r.powerListener != nil { monitor.Start("close power listener") @@ -249,6 +253,20 @@ func (r *NetworkManager) Close() error { }) monitor.Finish() } + if r.startedCancel != nil { + r.startedCancel() + monitor.Start("wait network reset") + r.resetRunAccess.Lock() + monitor.Finish() + defer r.resetRunAccess.Unlock() + } + if r.packageManager != nil { + monitor.Start("close package manager") + err = E.Append(err, r.packageManager.Close(), func(err error) error { + return E.Cause(err, "close package manager") + }) + monitor.Finish() + } if r.interfaceMonitor != nil { monitor.Start("close interface monitor") err = E.Append(err, r.interfaceMonitor.Close(), func(err error) error { @@ -256,14 +274,6 @@ func (r *NetworkManager) Close() error { }) monitor.Finish() } - r.interfaceUpdateAccess.Lock() - interfaceUpdateCancel := r.interfaceUpdateCancel - r.interfaceUpdateCancel = nil - r.interfaceUpdateAccess.Unlock() - if interfaceUpdateCancel != nil { - interfaceUpdateCancel() - } - r.cancelPowerUpdate() if r.networkMonitor != nil { monitor.Start("close network monitor") err = E.Append(err, r.networkMonitor.Close(), func(err error) error { @@ -516,7 +526,7 @@ func (r *NetworkManager) notifyInterfaceUpdate(defaultInterface *control.Interfa return } r.pauseManager.NetworkWake() - updateContext, updateCancel := context.WithCancel(r.ctx) + updateContext, updateCancel := context.WithCancel(r.startedCtx) r.interfaceUpdateAccess.Lock() previousCancel := r.interfaceUpdateCancel r.interfaceUpdateCancel = updateCancel @@ -531,8 +541,8 @@ func (r *NetworkManager) notifyInterfaceUpdate(defaultInterface *control.Interfa } func (r *NetworkManager) updateInterface(ctx context.Context, defaultInterface *control.Interface) { - r.interfaceUpdateRunAccess.Lock() - defer r.interfaceUpdateRunAccess.Unlock() + r.resetRunAccess.Lock() + defer r.resetRunAccess.Unlock() if ctx.Err() != nil { return } @@ -571,9 +581,6 @@ func (r *NetworkManager) updateInterface(ctx context.Context, defaultInterface * if ctx.Err() != nil { return } - if !r.started { - return - } r.ResetNetwork(ctx) } @@ -582,7 +589,7 @@ func (r *NetworkManager) notifyWindowsPowerEvent(event int) { case winpowrprof.EVENT_SUSPEND: r.pauseManager.DevicePause() r.cancelPowerUpdate() - r.ResetNetwork(r.ctx) + r.ResetNetwork(r.startedCtx) case winpowrprof.EVENT_RESUME: if !r.pauseManager.IsDevicePaused() { return @@ -590,7 +597,7 @@ func (r *NetworkManager) notifyWindowsPowerEvent(event int) { fallthrough case winpowrprof.EVENT_RESUME_AUTOMATIC: r.pauseManager.DeviceWake() - updateContext, updateCancel := context.WithCancel(r.ctx) + updateContext, updateCancel := context.WithCancel(r.startedCtx) r.powerUpdateAccess.Lock() previousCancel := r.powerUpdateCancel r.powerUpdateCancel = updateCancel @@ -600,6 +607,11 @@ func (r *NetworkManager) notifyWindowsPowerEvent(event int) { } go func() { defer updateCancel() + r.resetRunAccess.Lock() + defer r.resetRunAccess.Unlock() + if updateContext.Err() != nil { + return + } r.ResetNetwork(updateContext) }() } From 394285b78b34dfd91285fb1d1386ba7e56b9c71e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 15 Sep 2026 01:17:38 +0800 Subject: [PATCH 03/66] Fix TestFlight publish picking builds of other versions --- cmd/internal/app_store_connect/main.go | 34 +++++++++++++++++--------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/cmd/internal/app_store_connect/main.go b/cmd/internal/app_store_connect/main.go index 8c143dbe..6803c054 100644 --- a/cmd/internal/app_store_connect/main.go +++ b/cmd/internal/app_store_connect/main.go @@ -136,6 +136,8 @@ func publishTestflight(ctx context.Context) error { return err } tag := tagVersion.VersionString() + testFlightVersion := build_shared.TestFlightVersion(tagVersion) + projectVersion := os.Getenv(strings.ToUpper(os.Args[2]) + "_PROJECT_VERSION") releaseNotes := F.ToString("sing-box ", tagVersion.String()) if len(os.Args) >= 4 { @@ -153,26 +155,36 @@ func publishTestflight(ctx context.Context) error { return it.ID }) - waitingForProcess := false - log.Info(string(platform), " list builds") + query := &asc.ListBuildsQuery{ + FilterApp: []string{appID}, + FilterPreReleaseVersionPlatform: []string{string(platform)}, + FilterPreReleaseVersionVersion: []string{testFlightVersion}, + Sort: []string{"-uploadedDate"}, + Limit: 1, + } + if projectVersion != "" { + query.FilterVersion = []string{projectVersion} + } + log.Info(string(platform), " ", testFlightVersion, " (", projectVersion, ") list builds") for { - builds, _, err := client.Builds.ListBuilds(ctx, &asc.ListBuildsQuery{ - FilterApp: []string{appID}, - FilterPreReleaseVersionPlatform: []string{string(platform)}, - }) + builds, _, err := client.Builds.ListBuilds(ctx, query) if err != nil { return err } + if len(builds.Data) == 0 { + log.Info(string(platform), " ", testFlightVersion, " waiting for build upload") + time.Sleep(15 * time.Second) + continue + } build := builds.Data[0] - log.Info(string(platform), " ", tag, " found build: ", build.ID, " (", *build.Attributes.Version, ")") - if !waitingForProcess && (common.Contains(buildIDs, build.ID) || time.Since(build.Attributes.UploadedDate.Time) > 30*time.Minute) { - log.Info(string(platform), " ", tag, " waiting for process") + log.Info(string(platform), " ", testFlightVersion, " found build: ", build.ID, " (", *build.Attributes.Version, ")") + if projectVersion == "" && common.Contains(buildIDs, build.ID) { + log.Info(string(platform), " ", testFlightVersion, " build ", *build.Attributes.Version, " already published, waiting for new upload") time.Sleep(15 * time.Second) continue } if *build.Attributes.ProcessingState != "VALID" { - waitingForProcess = true - log.Info(string(platform), " ", tag, " waiting for process: ", *build.Attributes.ProcessingState) + log.Info(string(platform), " ", testFlightVersion, " waiting for process: ", *build.Attributes.ProcessingState) time.Sleep(15 * time.Second) continue } From 9fa1890aead15eb7c843096d427fc7fa21f81e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 16 Sep 2026 16:09:18 +0800 Subject: [PATCH 04/66] Add App Store review submission command --- Makefile | 10 +- cmd/internal/app_store_connect/main.go | 628 ++++++++++++++++--------- cmd/internal/build_shared/xcodeproj.go | 47 ++ go.mod | 2 +- go.sum | 4 +- 5 files changed, 459 insertions(+), 232 deletions(-) create mode 100644 cmd/internal/build_shared/xcodeproj.go diff --git a/Makefile b/Makefile index eaeb37ed..949248a4 100644 --- a/Makefile +++ b/Makefile @@ -224,7 +224,7 @@ update_apple_version: go run ./cmd/internal/update_apple_version update_macos_version: - MACOS_PROJECT_VERSION=$(shell go run -v ./cmd/internal/app_store_connect next_macos_project_version) go run ./cmd/internal/update_apple_version + MACOS_PROJECT_VERSION=$(shell go run ./cmd/internal/app_store_connect next_project_version macos) go run ./cmd/internal/update_apple_version release_apple: lib_apple update_apple_version release_ios release_macos release_tvos release_macos_standalone @@ -233,11 +233,13 @@ release_apple_beta: update_apple_version release_ios release_macos release_tvos publish_testflight: go run -v ./cmd/internal/app_store_connect publish_testflight $(filter-out $@,$(MAKECMDGOALS)) -prepare_app_store: - go run -v ./cmd/internal/app_store_connect prepare_app_store +submit_app_store: + go run -v ./cmd/internal/app_store_connect submit_app_store $(filter-out $@,$(MAKECMDGOALS)) + +release_app_store: release_ios release_tvos submit_app_store publish_app_store: - go run -v ./cmd/internal/app_store_connect publish_app_store + go run -v ./cmd/internal/app_store_connect publish_app_store $(filter-out $@,$(MAKECMDGOALS)) test: @go test -v ./... && \ diff --git a/cmd/internal/app_store_connect/main.go b/cmd/internal/app_store_connect/main.go index 6803c054..59b88281 100644 --- a/cmd/internal/app_store_connect/main.go +++ b/cmd/internal/app_store_connect/main.go @@ -4,12 +4,14 @@ import ( "context" "net/http" "os" + "slices" "strconv" "strings" "time" "github.com/sagernet/asc-go/asc" "github.com/sagernet/sing-box/cmd/internal/build_shared" + "github.com/sagernet/sing-box/common/badversion" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" @@ -32,18 +34,37 @@ func main() { if err != nil { log.Fatal(err) } + case "list_builds": + if len(os.Args) < 3 { + log.Fatal("platform required: ios, macos, or tvos") + } + err := listBuilds(ctx, os.Args[2]) + if err != nil { + log.Fatal(err) + } + case "remove_testflight": + if len(os.Args) < 5 { + log.Fatal("usage: remove_testflight ") + } + err := removeTestflight(ctx, os.Args[2], os.Args[3], os.Args[4]) + if err != nil { + log.Fatal(err) + } case "cancel_app_store": + if len(os.Args) < 3 { + log.Fatal("platform required: ios, macos, or tvos") + } err := cancelAppStore(ctx, os.Args[2]) if err != nil { log.Fatal(err) } - case "prepare_app_store": - err := prepareAppStore(ctx) + case "submit_app_store": + err := submitAppStore(ctx, os.Args[2:]) if err != nil { log.Fatal(err) } case "publish_app_store": - err := publishAppStore(ctx) + err := publishAppStore(ctx, os.Args[2:]) if err != nil { log.Fatal(err) } @@ -55,8 +76,35 @@ func main() { const ( appID = "6785326793" groupID = "39f9ebdc-05d4-421f-9595-dae71df227c4" + + applePath = "../sing-box-for-apple" + appStorePromotionalText = "The universal proxy platform." + appStoreWhatsNew = "Fixes and improvements" + buildPollInterval = 15 * time.Second ) +var ( + appStorePlatforms = []string{"ios", "tvos"} + platformDirectories = map[asc.Platform]string{ + asc.PlatformIOS: "SFI", + asc.PlatformMACOS: "SFM", + asc.PlatformTVOS: "SFT", + } +) + +func parsePlatform(name string) (asc.Platform, error) { + switch name { + case "ios": + return asc.PlatformIOS, nil + case "macos": + return asc.PlatformMACOS, nil + case "tvos": + return asc.PlatformTVOS, nil + default: + return "", E.New("unknown platform: ", name) + } +} + func createClient(expireDuration time.Duration) *asc.Client { privateKey, err := os.ReadFile(os.Getenv("ASC_KEY_PATH")) if err != nil { @@ -70,16 +118,9 @@ func createClient(expireDuration time.Duration) *asc.Client { } func fetchNextProjectVersion(ctx context.Context, platformName string) error { - var platform asc.Platform - switch platformName { - case "ios": - platform = asc.PlatformIOS - case "macos": - platform = asc.PlatformMACOS - case "tvos": - platform = asc.PlatformTVOS - default: - return E.New("unknown platform: ", platformName) + platform, err := parsePlatform(platformName) + if err != nil { + return err } query := &asc.ListBuildsQuery{ @@ -115,20 +156,55 @@ func fetchNextProjectVersion(ctx context.Context, platformName string) error { return nil } +func listBuilds(ctx context.Context, platformName string) error { + platform, err := parsePlatform(platformName) + if err != nil { + return err + } + client := createClient(time.Minute) + buildIDsResponse, _, err := client.TestFlight.ListBuildIDsForBetaGroup(ctx, groupID, nil) + if err != nil { + return err + } + betaBuildIDs := common.Map(buildIDsResponse.Data, func(it asc.RelationshipData) string { + return it.ID + }) + builds, _, err := client.Builds.ListBuilds(ctx, &asc.ListBuildsQuery{ + FilterApp: []string{appID}, + FilterPreReleaseVersionPlatform: []string{string(platform)}, + Include: []string{"preReleaseVersion"}, + Sort: []string{"-uploadedDate"}, + Limit: 10, + }) + if err != nil { + return err + } + for _, build := range builds.Data { + var betaGroup string + if slices.Contains(betaBuildIDs, build.ID) { + betaGroup = " testflight" + } + var versionString string + if build.Relationships != nil && build.Relationships.PreReleaseVersion != nil && build.Relationships.PreReleaseVersion.Data != nil { + for _, included := range builds.Included { + preReleaseVersion := included.PrereleaseVersion() + if preReleaseVersion != nil && preReleaseVersion.ID == build.Relationships.PreReleaseVersion.Data.ID { + versionString = *preReleaseVersion.Attributes.Version + } + } + } + os.Stdout.WriteString(F.ToString(versionString, " (", *build.Attributes.Version, ") ", *build.Attributes.ProcessingState, " ", build.Attributes.UploadedDate.Time.Format(time.RFC3339), betaGroup, "\n")) + } + return nil +} + func publishTestflight(ctx context.Context) error { if len(os.Args) < 3 { return E.New("platform required: ios, macos, or tvos") } - var platform asc.Platform - switch os.Args[2] { - case "ios": - platform = asc.PlatformIOS - case "macos": - platform = asc.PlatformMACOS - case "tvos": - platform = asc.PlatformTVOS - default: - return E.New("unknown platform: ", os.Args[2]) + platform, err := parsePlatform(os.Args[2]) + if err != nil { + return err } tagVersion, err := build_shared.ReadTagVersion() @@ -173,19 +249,19 @@ func publishTestflight(ctx context.Context) error { } if len(builds.Data) == 0 { log.Info(string(platform), " ", testFlightVersion, " waiting for build upload") - time.Sleep(15 * time.Second) + time.Sleep(buildPollInterval) continue } build := builds.Data[0] log.Info(string(platform), " ", testFlightVersion, " found build: ", build.ID, " (", *build.Attributes.Version, ")") if projectVersion == "" && common.Contains(buildIDs, build.ID) { log.Info(string(platform), " ", testFlightVersion, " build ", *build.Attributes.Version, " already published, waiting for new upload") - time.Sleep(15 * time.Second) + time.Sleep(buildPollInterval) continue } if *build.Attributes.ProcessingState != "VALID" { log.Info(string(platform), " ", testFlightVersion, " waiting for process: ", *build.Attributes.ProcessingState) - time.Sleep(15 * time.Second) + time.Sleep(buildPollInterval) continue } log.Info(string(platform), " ", tag, " list localizations") @@ -210,7 +286,7 @@ func publishTestflight(ctx context.Context) error { response, err := client.TestFlight.AddBuildsToBetaGroup(ctx, groupID, []string{build.ID}) if response != nil && (response.StatusCode == http.StatusUnprocessableEntity || response.StatusCode == http.StatusNotFound) { log.Info("waiting for process") - time.Sleep(15 * time.Second) + time.Sleep(buildPollInterval) continue } else if err != nil { return err @@ -238,227 +314,341 @@ func publishTestflight(ctx context.Context) error { return nil } -func cancelAppStore(ctx context.Context, platform string) error { - switch platform { - case "ios": - platform = string(asc.PlatformIOS) - case "macos": - platform = string(asc.PlatformMACOS) - case "tvos": - platform = string(asc.PlatformTVOS) - } - tag, err := build_shared.ReadTag() +func removeTestflight(ctx context.Context, platformName string, versionString string, buildNumber string) error { + platform, err := parsePlatform(platformName) if err != nil { return err } client := createClient(time.Minute) - for { - log.Info(platform, " list versions") - versions, response, err := client.Apps.ListAppStoreVersionsForApp(ctx, appID, &asc.ListAppStoreVersionsQuery{ - FilterPlatform: []string{string(platform)}, - }) - if isRetryable(response) { - continue - } else if err != nil { - return err - } - version := common.Find(versions.Data, func(it asc.AppStoreVersion) bool { - return *it.Attributes.VersionString == tag - }) - if version.ID == "" { - return nil - } - log.Info(platform, " ", tag, " get submission") - submission, response, err := client.Submission.GetAppStoreVersionSubmissionForAppStoreVersion(ctx, version.ID, nil) - if response != nil && response.StatusCode == http.StatusNotFound { - return nil - } - if isRetryable(response) { - continue - } else if err != nil { - return err - } - log.Info(platform, " ", tag, " delete submission") - _, err = client.Submission.DeleteSubmission(ctx, submission.Data.ID) - if err != nil { - return err - } - return nil + builds, _, err := client.Builds.ListBuilds(ctx, &asc.ListBuildsQuery{ + FilterApp: []string{appID}, + FilterPreReleaseVersionPlatform: []string{string(platform)}, + FilterPreReleaseVersionVersion: []string{versionString}, + FilterVersion: []string{buildNumber}, + }) + if err != nil { + return err } + if len(builds.Data) != 1 { + return E.New(string(platform), " ", versionString, " (", buildNumber, ") matched ", len(builds.Data), " builds") + } + build := builds.Data[0] + log.Info(string(platform), " ", versionString, " (", buildNumber, ") remove from beta group") + _, err = client.Builds.RemoveAccessForBetaGroupsFromBuild(ctx, build.ID, []string{groupID}) + if err != nil { + return err + } + return nil } -func prepareAppStore(ctx context.Context) error { +func cancelAppStore(ctx context.Context, platformName string) error { + platform, err := parsePlatform(platformName) + if err != nil { + return err + } tag, err := build_shared.ReadTag() if err != nil { return err } client := createClient(time.Minute) - for _, platform := range []asc.Platform{ - asc.PlatformIOS, - asc.PlatformMACOS, - asc.PlatformTVOS, - } { - log.Info(string(platform), " list versions") - versions, _, err := client.Apps.ListAppStoreVersionsForApp(ctx, appID, &asc.ListAppStoreVersionsQuery{ - FilterPlatform: []string{string(platform)}, - }) + log.Info(string(platform), " list versions") + versions, _, err := client.Apps.ListAppStoreVersionsForApp(ctx, appID, &asc.ListAppStoreVersionsQuery{ + FilterPlatform: []string{string(platform)}, + FilterVersionString: []string{tag}, + }) + if err != nil { + return err + } + if len(versions.Data) == 0 { + return nil + } + versionID := versions.Data[0].ID + log.Info(string(platform), " ", tag, " list review submissions") + submissions, _, err := client.Submission.ListReviewSubmissions(ctx, &asc.ListReviewSubmissionsQuery{ + FilterApp: []string{appID}, + FilterPlatform: []string{string(platform)}, + FilterState: []string{ + string(asc.ReviewSubmissionStateReadyForReview), + string(asc.ReviewSubmissionStateWaitingForReview), + string(asc.ReviewSubmissionStateInReview), + }, + }) + if err != nil { + return err + } + for _, submission := range submissions.Data { + containsVersion, err := reviewSubmissionContainsVersion(ctx, client, submission.ID, versionID) if err != nil { return err } - version := common.Find(versions.Data, func(it asc.AppStoreVersion) bool { - return *it.Attributes.VersionString == tag - }) - log.Info(string(platform), " ", tag, " list builds") - builds, _, err := client.Builds.ListBuilds(ctx, &asc.ListBuildsQuery{ - FilterApp: []string{appID}, - FilterPreReleaseVersionPlatform: []string{string(platform)}, - }) + if !containsVersion { + continue + } + log.Info(string(platform), " ", tag, " cancel review submission ", submission.ID) + _, _, err = client.Submission.UpdateReviewSubmission(ctx, submission.ID, nil, common.Ptr(true)) if err != nil { return err } - if len(builds.Data) == 0 { - log.Fatal(string(platform), " ", tag, " no build found") - } - buildID := common.Ptr(builds.Data[0].ID) - if version.ID == "" { - log.Info(string(platform), " ", tag, " create version") - newVersion, _, err := client.Apps.CreateAppStoreVersion(ctx, asc.AppStoreVersionCreateRequestAttributes{ - Platform: platform, - VersionString: tag, - }, appID, buildID) - if err != nil { - return err - } - version = newVersion.Data - - } else { - log.Info(string(platform), " ", tag, " check build") - currentBuild, response, err := client.Apps.GetBuildIDForAppStoreVersion(ctx, version.ID) - if err != nil { - return err - } - if response.StatusCode != http.StatusOK || currentBuild.Data.ID != *buildID { - switch *version.Attributes.AppStoreState { - case asc.AppStoreVersionStatePrepareForSubmission, - asc.AppStoreVersionStateRejected, - asc.AppStoreVersionStateDeveloperRejected: - case asc.AppStoreVersionStateWaitingForReview, - asc.AppStoreVersionStateInReview, - asc.AppStoreVersionStatePendingDeveloperRelease: - submission, _, err := client.Submission.GetAppStoreVersionSubmissionForAppStoreVersion(ctx, version.ID, nil) - if err != nil { - return err - } - if submission != nil { - log.Info(string(platform), " ", tag, " delete submission") - _, err = client.Submission.DeleteSubmission(ctx, submission.Data.ID) - if err != nil { - return err - } - time.Sleep(5 * time.Second) - } - default: - log.Fatal(string(platform), " ", tag, " unknown state ", string(*version.Attributes.AppStoreState)) - } - log.Info(string(platform), " ", tag, " update build") - response, err = client.Apps.UpdateBuildForAppStoreVersion(ctx, version.ID, buildID) - if err != nil { - return err - } - if response.StatusCode != http.StatusNoContent { - response.Write(os.Stderr) - log.Fatal(string(platform), " ", tag, " unexpected response: ", response.Status) - } - } else { - switch *version.Attributes.AppStoreState { - case asc.AppStoreVersionStatePrepareForSubmission, - asc.AppStoreVersionStateRejected, - asc.AppStoreVersionStateDeveloperRejected: - case asc.AppStoreVersionStateWaitingForReview, - asc.AppStoreVersionStateInReview, - asc.AppStoreVersionStatePendingDeveloperRelease: - continue - default: - log.Fatal(string(platform), " ", tag, " unknown state ", string(*version.Attributes.AppStoreState)) - } - } - } - log.Info(string(platform), " ", tag, " list localization") - localizations, _, err := client.Apps.ListLocalizationsForAppStoreVersion(ctx, version.ID, nil) - if err != nil { - return err - } - localization := common.Find(localizations.Data, func(it asc.AppStoreVersionLocalization) bool { - return *it.Attributes.Locale == "en-US" - }) - if localization.ID == "" { - log.Info(string(platform), " ", tag, " no en-US localization found") - } - if localization.Attributes == nil || localization.Attributes.WhatsNew == nil || *localization.Attributes.WhatsNew == "" { - log.Info(string(platform), " ", tag, " update localization") - _, _, err = client.Apps.UpdateAppStoreVersionLocalization(ctx, localization.ID, &asc.AppStoreVersionLocalizationUpdateRequestAttributes{ - PromotionalText: common.Ptr("Yet another distribution for sing-box, the universal proxy platform."), - WhatsNew: common.Ptr(F.ToString("sing-box ", tag, ": Fixes and improvements.")), - }) - if err != nil { - return err - } - } - log.Info(string(platform), " ", tag, " create submission") - fixSubmit: - for { - _, response, err := client.Submission.CreateSubmission(ctx, version.ID) - if err != nil { - switch response.StatusCode { - case http.StatusInternalServerError: - continue - default: - return err - } - } - switch response.StatusCode { - case http.StatusCreated: - break fixSubmit - default: - return err - } - } } return nil } -func publishAppStore(ctx context.Context) error { - tag, err := build_shared.ReadTag() +func reviewSubmissionContainsVersion(ctx context.Context, client *asc.Client, submissionID string, versionID string) (bool, error) { + items, _, err := client.Submission.ListItemsForReviewSubmission(ctx, submissionID, nil) + if err != nil { + return false, err + } + return slices.ContainsFunc(items.Data, func(it asc.ReviewSubmissionItem) bool { + return it.Relationships != nil && it.Relationships.AppStoreVersion != nil && + it.Relationships.AppStoreVersion.Data != nil && it.Relationships.AppStoreVersion.Data.ID == versionID + }), nil +} + +func submitAppStore(ctx context.Context, platformNames []string) error { + if len(platformNames) == 0 { + platformNames = appStorePlatforms + } + client := createClient(20 * time.Minute) + for _, platformName := range platformNames { + platform, err := parsePlatform(platformName) + if err != nil { + return err + } + versionString, err := build_shared.ReadAppleMarketingVersion(applePath, platformDirectories[platform]) + if err != nil { + return err + } + err = submitAppStoreVersion(ctx, client, platform, versionString) + if err != nil { + return err + } + } + return nil +} + +func submitAppStoreVersion(ctx context.Context, client *asc.Client, platform asc.Platform, versionString string) error { + log.Info(string(platform), " ", versionString, " list versions") + versions, _, err := client.Apps.ListAppStoreVersionsForApp(ctx, appID, &asc.ListAppStoreVersionsQuery{ + FilterPlatform: []string{string(platform)}, + }) if err != nil { return err } - client := createClient(time.Minute) - for _, platform := range []asc.Platform{ - asc.PlatformIOS, - asc.PlatformMACOS, - asc.PlatformTVOS, - } { - log.Info(string(platform), " list versions") - versions, _, err := client.Apps.ListAppStoreVersionsForApp(ctx, appID, &asc.ListAppStoreVersionsQuery{ - FilterPlatform: []string{string(platform)}, + localVersion := badversion.Parse(versionString) + var version asc.AppStoreVersion + for _, remoteVersion := range versions.Data { + remoteVersionString := *remoteVersion.Attributes.VersionString + if remoteVersionString == versionString { + version = remoteVersion + continue + } + if !badversion.Parse(remoteVersionString).LessThan(localVersion) { + return E.New(string(platform), " ", versionString, " is not newer than App Store version ", remoteVersionString) + } + } + if version.ID != "" { + state := *version.Attributes.AppStoreState + switch state { + case asc.AppStoreVersionStatePrepareForSubmission, + asc.AppStoreVersionStateRejected, + asc.AppStoreVersionStateDeveloperRejected, + asc.AppStoreVersionStateMetadataRejected, + asc.AppStoreVersionStateInvalidBinary: + log.Info(string(platform), " ", versionString, " found version in state ", string(state)) + case asc.AppStoreVersionStateWaitingForReview, asc.AppStoreVersionStateInReview: + log.Info(string(platform), " ", versionString, " already submitted: ", string(state)) + return nil + case asc.AppStoreVersionStatePendingDeveloperRelease: + log.Info(string(platform), " ", versionString, " approved, run publish_app_store to release") + return nil + default: + return E.New(string(platform), " ", versionString, " already released: ", string(state)) + } + } else { + log.Info(string(platform), " ", versionString, " create version") + created, _, err := client.Apps.CreateAppStoreVersion(ctx, asc.AppStoreVersionCreateRequestAttributes{ + Platform: platform, + ReleaseType: common.Ptr("MANUAL"), + VersionString: versionString, + }, appID, nil) + if err != nil { + return err + } + version = created.Data + } + + log.Info(string(platform), " ", versionString, " list localizations") + localizations, _, err := client.Apps.ListLocalizationsForAppStoreVersion(ctx, version.ID, nil) + if err != nil { + return err + } + localization := common.Find(localizations.Data, func(it asc.AppStoreVersionLocalization) bool { + return *it.Attributes.Locale == "en-US" + }) + if localization.ID == "" { + log.Info(string(platform), " ", versionString, " create en-US localization") + _, _, err = client.Apps.CreateAppStoreVersionLocalization(ctx, asc.AppStoreVersionLocalizationCreateRequestAttributes{ + Locale: "en-US", + PromotionalText: common.Ptr(appStorePromotionalText), + WhatsNew: common.Ptr(appStoreWhatsNew), + }, version.ID) + if err != nil { + return err + } + } else if localization.Attributes.PromotionalText == nil || *localization.Attributes.PromotionalText != appStorePromotionalText || + localization.Attributes.WhatsNew == nil || *localization.Attributes.WhatsNew != appStoreWhatsNew { + log.Info(string(platform), " ", versionString, " update en-US localization") + _, _, err = client.Apps.UpdateAppStoreVersionLocalization(ctx, localization.ID, &asc.AppStoreVersionLocalizationUpdateRequestAttributes{ + PromotionalText: common.Ptr(appStorePromotionalText), + WhatsNew: common.Ptr(appStoreWhatsNew), }) if err != nil { return err } - version := common.Find(versions.Data, func(it asc.AppStoreVersion) bool { - return *it.Attributes.VersionString == tag + } + + var build asc.Build + for { + builds, _, err := client.Builds.ListBuilds(ctx, &asc.ListBuildsQuery{ + FilterApp: []string{appID}, + FilterPreReleaseVersionPlatform: []string{string(platform)}, + FilterPreReleaseVersionVersion: []string{versionString}, + Sort: []string{"-uploadedDate"}, + Limit: 1, }) - switch *version.Attributes.AppStoreState { + if err != nil { + return err + } + if len(builds.Data) == 0 { + log.Info(string(platform), " ", versionString, " waiting for build upload") + time.Sleep(buildPollInterval) + continue + } + build = builds.Data[0] + processingState := *build.Attributes.ProcessingState + if processingState == "VALID" { + break + } + if processingState == "PROCESSING" { + log.Info(string(platform), " ", versionString, " waiting for build ", *build.Attributes.Version, ": ", processingState) + time.Sleep(buildPollInterval) + continue + } + return E.New(string(platform), " ", versionString, " build ", *build.Attributes.Version, " ", processingState) + } + log.Info(string(platform), " ", versionString, " found build ", *build.Attributes.Version) + + currentBuild, response, err := client.Apps.GetBuildIDForAppStoreVersion(ctx, version.ID) + if err != nil { + return err + } + if response.StatusCode != http.StatusOK || currentBuild.Data.ID != build.ID { + log.Info(string(platform), " ", versionString, " attach build ", *build.Attributes.Version) + _, err = client.Apps.UpdateBuildForAppStoreVersion(ctx, version.ID, common.Ptr(build.ID)) + if err != nil { + return err + } + } + + log.Info(string(platform), " ", versionString, " list review submissions") + submissions, _, err := client.Submission.ListReviewSubmissions(ctx, &asc.ListReviewSubmissionsQuery{ + FilterApp: []string{appID}, + FilterPlatform: []string{string(platform)}, + FilterState: []string{ + string(asc.ReviewSubmissionStateReadyForReview), + string(asc.ReviewSubmissionStateWaitingForReview), + string(asc.ReviewSubmissionStateInReview), + string(asc.ReviewSubmissionStateUnresolvedIssues), + }, + }) + if err != nil { + return err + } + var ( + submission asc.ReviewSubmission + submissionHasItem bool + ) + for _, remoteSubmission := range submissions.Data { + containsVersion, err := reviewSubmissionContainsVersion(ctx, client, remoteSubmission.ID, version.ID) + if err != nil { + return err + } + state := *remoteSubmission.Attributes.State + switch state { + case asc.ReviewSubmissionStateReadyForReview: + submission = remoteSubmission + submissionHasItem = containsVersion + case asc.ReviewSubmissionStateWaitingForReview, asc.ReviewSubmissionStateInReview: + if containsVersion { + log.Info(string(platform), " ", versionString, " already submitted: ", string(state)) + return nil + } + return E.New(string(platform), " review submission ", remoteSubmission.ID, " with another version is ", string(state)) + default: + return E.New(string(platform), " review submission ", remoteSubmission.ID, " is ", string(state)) + } + } + if submission.ID == "" { + log.Info(string(platform), " ", versionString, " create review submission") + created, _, err := client.Submission.CreateReviewSubmission(ctx, appID, common.Ptr(platform)) + if err != nil { + return err + } + submission = created.Data + } + if !submissionHasItem { + log.Info(string(platform), " ", versionString, " add version to review submission ", submission.ID) + _, _, err = client.Submission.CreateReviewSubmissionItemForAppStoreVersion(ctx, submission.ID, version.ID) + if err != nil { + return err + } + } + log.Info(string(platform), " ", versionString, " submit review submission ", submission.ID) + _, _, err = client.Submission.UpdateReviewSubmission(ctx, submission.ID, common.Ptr(true), nil) + if err != nil { + return err + } + return nil +} + +func publishAppStore(ctx context.Context, platformNames []string) error { + if len(platformNames) == 0 { + platformNames = appStorePlatforms + } + client := createClient(time.Minute) + for _, platformName := range platformNames { + platform, err := parsePlatform(platformName) + if err != nil { + return err + } + versionString, err := build_shared.ReadAppleMarketingVersion(applePath, platformDirectories[platform]) + if err != nil { + return err + } + log.Info(string(platform), " ", versionString, " list versions") + versions, _, err := client.Apps.ListAppStoreVersionsForApp(ctx, appID, &asc.ListAppStoreVersionsQuery{ + FilterPlatform: []string{string(platform)}, + FilterVersionString: []string{versionString}, + }) + if err != nil { + return err + } + if len(versions.Data) == 0 { + return E.New(string(platform), " ", versionString, " not found") + } + version := versions.Data[0] + state := *version.Attributes.AppStoreState + switch state { case asc.AppStoreVersionStatePrepareForSubmission, asc.AppStoreVersionStateDeveloperRejected: - log.Fatal(string(platform), " ", tag, " not submitted") - case asc.AppStoreVersionStateWaitingForReview, - asc.AppStoreVersionStateInReview: - log.Warn(string(platform), " ", tag, " waiting for review") + return E.New(string(platform), " ", versionString, " not submitted") + case asc.AppStoreVersionStateWaitingForReview, asc.AppStoreVersionStateInReview: + log.Warn(string(platform), " ", versionString, " waiting for review") continue case asc.AppStoreVersionStatePendingDeveloperRelease: default: - log.Fatal(string(platform), " ", tag, " unknown state ", string(*version.Attributes.AppStoreState)) + return E.New(string(platform), " ", versionString, " unknown state ", string(state)) } + log.Info(string(platform), " ", versionString, " release") _, _, err = client.Publishing.CreatePhasedRelease(ctx, common.Ptr(asc.PhasedReleaseStateComplete), version.ID) if err != nil { return err @@ -466,15 +656,3 @@ func publishAppStore(ctx context.Context) error { } return nil } - -func isRetryable(response *asc.Response) bool { - if response == nil { - return false - } - switch response.StatusCode { - case http.StatusInternalServerError, http.StatusUnprocessableEntity: - return true - default: - return false - } -} diff --git a/cmd/internal/build_shared/xcodeproj.go b/cmd/internal/build_shared/xcodeproj.go new file mode 100644 index 00000000..a7117a35 --- /dev/null +++ b/cmd/internal/build_shared/xcodeproj.go @@ -0,0 +1,47 @@ +package build_shared + +import ( + "os" + "path/filepath" + "strings" + + E "github.com/sagernet/sing/common/exceptions" + + "howett.net/plist" +) + +func ReadAppleMarketingVersion(applePath string, directory string) (string, error) { + projectFile, err := os.Open(filepath.Join(applePath, "sing-box.xcodeproj", "project.pbxproj")) + if err != nil { + return "", err + } + defer projectFile.Close() + var project map[string]any + err = plist.NewDecoder(projectFile).Decode(&project) + if err != nil { + return "", err + } + var marketingVersion string + for _, object := range project["objects"].(map[string]any) { + buildSettings, isMap := object.(map[string]any)["buildSettings"].(map[string]any) + if !isMap { + continue + } + infoPlistFile, isString := buildSettings["INFOPLIST_FILE"].(string) + if !isString || !strings.HasPrefix(infoPlistFile, directory+"/") { + continue + } + version, isString := buildSettings["MARKETING_VERSION"].(string) + if !isString { + continue + } + if marketingVersion != "" && marketingVersion != version { + return "", E.New("inconsistent marketing versions in ", directory, ": ", marketingVersion, ", ", version) + } + marketingVersion = version + } + if marketingVersion == "" { + return "", E.New("marketing version not found in ", directory) + } + return marketingVersion, nil +} diff --git a/go.mod b/go.mod index a49a36e3..318d7c49 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( github.com/openai/openai-go/v3 v3.26.0 github.com/oschwald/maxminddb-golang v1.13.1 github.com/pkg/sftp v1.13.10 - github.com/sagernet/asc-go v0.0.0-20260827085112-8f8226245b0d + github.com/sagernet/asc-go v0.0.0-20260914163356-9e3d45a797c1 github.com/sagernet/bbolt v0.0.0-20260915102804-500ee1e84832 github.com/sagernet/cors v1.2.1 github.com/sagernet/cronet-go v0.0.0-20260912104727-0d28acc44093 diff --git a/go.sum b/go.sum index 7c6d6b9b..48412202 100644 --- a/go.sum +++ b/go.sum @@ -236,8 +236,8 @@ github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9q 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-20260827085112-8f8226245b0d h1:r3JBMjxwI8BPLprJpklmcgoPT3r7rJfUg4uuGWky48A= -github.com/sagernet/asc-go v0.0.0-20260827085112-8f8226245b0d/go.mod h1:JULDuzTMn2gyZFcjpTVZP4/UuwAdbHJ0bum2RdjXojU= +github.com/sagernet/asc-go v0.0.0-20260914163356-9e3d45a797c1 h1:fOZrud4hyHSHPpgTV0qicYziKru209IApFWamdRab50= +github.com/sagernet/asc-go v0.0.0-20260914163356-9e3d45a797c1/go.mod h1:JULDuzTMn2gyZFcjpTVZP4/UuwAdbHJ0bum2RdjXojU= github.com/sagernet/bbolt v0.0.0-20260915102804-500ee1e84832 h1:KDXMGbzqLmDdKcld0T3wuMuqf1iezZq+DemgtZ/vhtQ= github.com/sagernet/bbolt v0.0.0-20260915102804-500ee1e84832/go.mod h1:7/qFPFH5fqK5XQeY8EA1Qpaly08Av77p38Zt50ieQww= github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ= From d7cc756eba8efc423e43039ba722ba315699ef79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 16 Sep 2026 16:09:18 +0800 Subject: [PATCH 05/66] Fix TestFlight publish being skipped on stable --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 049399e4..d8d61601 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1559,7 +1559,7 @@ jobs: --api-key $ASC_KEY_ID \ --api-issuer 00000000-0000-0000-0000-000000000000 - name: Publish to TestFlight - if: matrix.if && matrix.type == 'app-store' && github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/testing' + if: matrix.if && matrix.type == 'app-store' && github.event_name == 'workflow_dispatch' run: |- arguments=(publish_testflight "${{ matrix.platform }}") if [[ -n "$TEST_MESSAGE" ]]; then From f5f4995c3243514231506f99bc420583c55d15e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 16 Sep 2026 17:53:59 +0800 Subject: [PATCH 06/66] Fix systemd daemon reload after package installation --- release/config/sing-box.postinst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/release/config/sing-box.postinst b/release/config/sing-box.postinst index 770dd22a..ac1e4efd 100644 --- a/release/config/sing-box.postinst +++ b/release/config/sing-box.postinst @@ -1,3 +1,9 @@ #!/bin/sh +set -e + systemd-sysusers sing-box.conf + +if [ -d /run/systemd/system ]; then + systemctl --system daemon-reload >/dev/null || true +fi From c0b8fe8b57a50d6cfb8ae6529c52b5656f1e1f24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 16 Sep 2026 19:49:44 +0800 Subject: [PATCH 07/66] Fix API dashboard using implicit default HTTP client --- docs/configuration/service/api.md | 13 ++--------- docs/configuration/service/api.zh.md | 10 ++------- service/api/dashboard.go | 33 +++++++++------------------- 3 files changed, 14 insertions(+), 42 deletions(-) diff --git a/docs/configuration/service/api.md b/docs/configuration/service/api.md index 9baf7142..b7712050 100644 --- a/docs/configuration/service/api.md +++ b/docs/configuration/service/api.md @@ -92,20 +92,11 @@ Download URL of the dashboard archive (zip). ##### http_client -HTTP client used to download the dashboard, with the same behavior as remote rule-sets. +HTTP client used to download the dashboard. See [HTTP Client Fields](/configuration/shared/http-client/) for details. -When empty, the default HTTP client is used: the one named by -[`default_http_client`](/configuration/route/#default_http_client), or the first top-level -`http_clients` entry when `default_http_client` is empty. - -!!! failure "Implicit default deprecated in sing-box 1.14.0" - - When neither `http_clients` nor `default_http_client` is configured, an implicit HTTP - client connecting through the default outbound is used. This implicit default is - deprecated in sing-box 1.14.0 and will be removed in sing-box 1.16.0; define - `http_clients` instead. +Not used when the dashboard directory contains user-provided files. ##### update_interval diff --git a/docs/configuration/service/api.zh.md b/docs/configuration/service/api.zh.md index 521ca5c9..5b51ef3e 100644 --- a/docs/configuration/service/api.zh.md +++ b/docs/configuration/service/api.zh.md @@ -87,17 +87,11 @@ API 密钥。 ##### http_client -用于下载仪表板的 HTTP 客户端,行为与远程规则集相同。 +用于下载仪表板的 HTTP 客户端。 参阅 [HTTP 客户端字段](/zh/configuration/shared/http-client/)。 -留空时使用默认 HTTP 客户端:即由 [`default_http_client`](/zh/configuration/route/#default_http_client) -指定的客户端,或当 `default_http_client` 为空时使用顶级 `http_clients` 的第一项。 - -!!! failure "隐式默认已在 sing-box 1.14.0 废弃" - - 当 `http_clients` 与 `default_http_client` 均未配置时,将使用通过默认出站连接的隐式 HTTP 客户端。 - 该隐式默认已在 sing-box 1.14.0 废弃,并将在 sing-box 1.16.0 移除;请改为定义 `http_clients`。 +当仪表板目录包含用户提供的文件时不使用。 ##### update_interval diff --git a/service/api/dashboard.go b/service/api/dashboard.go index a522335f..e03dabe8 100644 --- a/service/api/dashboard.go +++ b/service/api/dashboard.go @@ -13,6 +13,7 @@ import ( "github.com/sagernet/sing-box/adapter" "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/service" "github.com/sagernet/sing/service/filemanager" @@ -78,12 +79,18 @@ func (d *dashboard) start() error { if err != nil && !os.IsNotExist(err) { return E.Cause(err, "read dashboard directory") } - transport, err := d.resolveTransport() + status := d.loadState() + if status == dashboardUserProvided { + d.logger.Info("dashboard: serving user-provided files at ", d.path, ", auto-update disabled") + return nil + } + httpClientManager := service.FromContext[adapter.HTTPClientManager](d.ctx) + transport, err := httpClientManager.ResolveTransport(d.ctx, d.logger, common.PtrValueOrDefault(d.options.HTTPClient)) if err != nil { return E.Cause(err, "create dashboard http client") } d.httpClient = &http.Client{Transport: transport} - go d.loopUpdate() + go d.loopUpdate(status) return nil } @@ -95,21 +102,6 @@ func (d *dashboard) close() error { return nil } -func (d *dashboard) resolveTransport() (adapter.HTTPTransport, error) { - httpClientManager := service.FromContext[adapter.HTTPClientManager](d.ctx) - if httpClientManager == nil { - return nil, E.New("missing http client manager in context") - } - if d.options.HTTPClient != nil && !d.options.HTTPClient.IsEmpty() { - return httpClientManager.ResolveTransport(d.ctx, d.logger, *d.options.HTTPClient) - } - defaultTransport := httpClientManager.DefaultTransport() - if defaultTransport == nil { - return nil, E.New("default http client transport is not initialized") - } - return defaultTransport, nil -} - func (d *dashboard) serveHTTP(writer http.ResponseWriter, request *http.Request) { if strings.HasPrefix(request.URL.Path, dashboardRoutePrefix) { d.fileServer.ServeHTTP(writer, request) @@ -118,12 +110,7 @@ func (d *dashboard) serveHTTP(writer http.ResponseWriter, request *http.Request) http.Redirect(writer, request, dashboardRoutePrefix, http.StatusFound) } -func (d *dashboard) loopUpdate() { - status := d.loadState() - if status == dashboardUserProvided { - d.logger.Info("dashboard: serving user-provided files at ", d.path, ", auto-update disabled") - return - } +func (d *dashboard) loopUpdate(status dashboardStatus) { var nextUpdate time.Time if status == dashboardManaged { nextUpdate = d.lastUpdated.Add(d.updateInterval) From 5ae597bfba77885a04e43729e7c918669f03259e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 17 Sep 2026 12:23:27 +0800 Subject: [PATCH 08/66] documentation: Fix default TLS min_version --- docs/configuration/shared/tls.md | 3 +-- docs/configuration/shared/tls.zh.md | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/configuration/shared/tls.md b/docs/configuration/shared/tls.md index 5929962c..0b3adc15 100644 --- a/docs/configuration/shared/tls.md +++ b/docs/configuration/shared/tls.md @@ -295,8 +295,7 @@ See [Application-Layer Protocol Negotiation](https://en.wikipedia.org/wiki/Appli The minimum TLS version that is acceptable. -By default, TLS 1.2 is currently used as the minimum when acting as a -client, and TLS 1.0 when acting as a server. +TLS 1.2 is used by default. #### max_version diff --git a/docs/configuration/shared/tls.zh.md b/docs/configuration/shared/tls.zh.md index e6ded13a..9a86eb34 100644 --- a/docs/configuration/shared/tls.zh.md +++ b/docs/configuration/shared/tls.zh.md @@ -294,7 +294,7 @@ TLS 版本值: 可接受的最低 TLS 版本。 -默认情况下,当前使用 TLS 1.2 作为客户端的最低要求。作为服务器时使用 TLS 1.0。 +默认使用 TLS 1.2。 #### max_version From 96b004bab1cac651d4d084a68380a182e4e3559e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 17 Sep 2026 15:34:04 +0800 Subject: [PATCH 09/66] Fix initial WireGuard handshake for domain peers --- go.mod | 2 +- go.sum | 4 ++-- transport/wireguard/endpoint.go | 39 +++++++++++++++++++-------------- 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/go.mod b/go.mod index 318d7c49..666a03e4 100644 --- a/go.mod +++ b/go.mod @@ -60,7 +60,7 @@ require ( github.com/sagernet/sing-vmess v0.2.8 github.com/sagernet/smux v1.5.50-sing-box-mod.1 github.com/sagernet/tailscale v1.102.1-sing-box-1.14-mod.5 - github.com/sagernet/wireguard-go v0.0.6 + github.com/sagernet/wireguard-go v0.0.7 github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.12.0 diff --git a/go.sum b/go.sum index 48412202..95579cb2 100644 --- a/go.sum +++ b/go.sum @@ -348,8 +348,8 @@ github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1h github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= github.com/sagernet/tailscale v1.102.1-sing-box-1.14-mod.5 h1:mGLWVgj2Pl2NlgE1+sEUIDIMB42JdWEPwYmxTnVWg44= github.com/sagernet/tailscale v1.102.1-sing-box-1.14-mod.5/go.mod h1:WLUSOPmTcf7VN9gLCe01qUSIvD+/cKC177neENyZPkI= -github.com/sagernet/wireguard-go v0.0.6 h1:zAjWSzufYThDsyLHxPLyyR+XdpBMlFDWD7WxJAvynSw= -github.com/sagernet/wireguard-go v0.0.6/go.mod h1:er10sELpmzLXq7S7Pbc1Zsbyapcr+/gxNAHKTo6fzVA= +github.com/sagernet/wireguard-go v0.0.7 h1:FmP7QY5OcFlhKtDVB81Xd+uoBv4BjW5GORBTUYwpuu0= +github.com/sagernet/wireguard-go v0.0.7/go.mod h1:er10sELpmzLXq7S7Pbc1Zsbyapcr+/gxNAHKTo6fzVA= 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/smallstep/pkcs7 v0.1.1 h1:x+rPdt2W088V9Vkjho4KtoggyktZJlMduZAtRHm68LU= diff --git a/transport/wireguard/endpoint.go b/transport/wireguard/endpoint.go index 20edc21a..7237c09c 100644 --- a/transport/wireguard/endpoint.go +++ b/transport/wireguard/endpoint.go @@ -202,28 +202,25 @@ func (e *Endpoint) Start(postStart bool) error { } wgDevice := device.NewDevice(e.options.Context, e.returnDevice, bind, logger, e.options.Workers) e.tunDevice.SetDevice(wgDevice) - var ipcConf strings.Builder - ipcConf.WriteString(e.ipcConf) - for _, peer := range e.peers { - ipcConf.WriteString(peer.GenerateIpcLines()) - } - err = wgDevice.IpcSet(ipcConf.String()) - if err != nil { - wgDevice.Close() - return E.Cause(err, "setup wireguard: \n", ipcConf.String()) - } - for _, peer := range e.peers { + domainPeers := make(map[device.NoisePublicKey]*peerConfig) + for peerIndex, peer := range e.peers { if !peer.destination.IsDomain() { continue } var publicKey device.NoisePublicKey - common.Must(publicKey.FromHex(peer.publicKeyHex)) - wgPeer, found := wgDevice.LookupActivePeer(publicKey) - if !found { + err = publicKey.FromHex(peer.publicKeyHex) + if err != nil { wgDevice.Close() - return E.New("missing configured peer: ", peer.destination) + return E.Cause(err, "decode public key for peer ", peerIndex) } - wgPeer.SetEndpointResolver(func() ([]conn.Endpoint, error) { + domainPeers[publicKey] = &e.peers[peerIndex] + } + if len(domainPeers) > 0 { + wgDevice.SetEndpointResolverFunc(func(publicKey device.NoisePublicKey) ([]conn.Endpoint, error) { + peer, found := domainPeers[publicKey] + if !found { + return nil, nil + } addresses, lookupErr := e.options.ResolvePeer(peer.destination.Fqdn) if lookupErr != nil { return nil, lookupErr @@ -243,6 +240,16 @@ func (e *Endpoint) Start(postStart bool) error { return endpoints, nil }) } + var ipcConf strings.Builder + ipcConf.WriteString(e.ipcConf) + for _, peer := range e.peers { + ipcConf.WriteString(peer.GenerateIpcLines()) + } + err = wgDevice.IpcSet(ipcConf.String()) + if err != nil { + wgDevice.Close() + return E.Cause(err, "setup wireguard: \n", ipcConf.String()) + } e.device = wgDevice e.pause = service.FromContext[pause.Manager](e.options.Context) if e.pause != nil { From cd568a563b0cb31ca0aecc20fa3a61c1850cc780 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 17 Sep 2026 17:41:47 +0800 Subject: [PATCH 10/66] Fix command client cancellation during connection --- experimental/libbox/command_client.go | 223 +++++++++++-------- experimental/libbox/command_client_remote.go | 7 +- 2 files changed, 132 insertions(+), 98 deletions(-) diff --git a/experimental/libbox/command_client.go b/experimental/libbox/command_client.go index 5c5ad912..3d286003 100644 --- a/experimental/libbox/command_client.go +++ b/experimental/libbox/command_client.go @@ -31,7 +31,8 @@ type CommandClient struct { remote *remoteConnection ctx context.Context cancel context.CancelFunc - clientMutex sync.RWMutex + clientAccess sync.RWMutex + connectAccess sync.Mutex standalone bool } @@ -80,13 +81,21 @@ func SetXPCDialer(dialer XPCDialer) { } func NewStandaloneCommandClient() *CommandClient { - return &CommandClient{standalone: true} + ctx, cancel := context.WithCancel(context.Background()) + return &CommandClient{ + ctx: ctx, + cancel: cancel, + standalone: true, + } } func NewCommandClient(handler CommandClientHandler, options *CommandClientOptions) *CommandClient { + ctx, cancel := context.WithCancel(context.Background()) return &CommandClient{ handler: handler, options: common.PtrValueOrDefault(options), + ctx: ctx, + cancel: cancel, } } @@ -163,12 +172,12 @@ func localDialOptions(contextDialer func(context.Context, string) (net.Conn, err // establishConnection dials the command server the client is bound to: the // local command server (over socket/XPC) or a remote API service. -func (c *CommandClient) establishConnection() (*grpc.ClientConn, daemon.StartedServiceClient, error) { +func (c *CommandClient) establishConnection(ctx context.Context) (*grpc.ClientConn, daemon.StartedServiceClient, error) { if c.remote != nil { - return c.dialRemote() + return c.dialRemote(ctx) } target, contextDialer := dialTarget() - return c.dialWithRetry(target, localDialOptions(contextDialer), !c.standalone) + return c.dialWithRetry(ctx, target, localDialOptions(contextDialer), !c.standalone) } // dialWithRetry connects to the local command server. For a handler-bound @@ -178,15 +187,15 @@ func (c *CommandClient) establishConnection() (*grpc.ClientConn, daemon.StartedS // "unavailable" to the UI. A standalone client issues a single fail-fast // probe instead: it serves a query from a UI that does not own the service // lifecycle, and a server that is not running is reported immediately. -func (c *CommandClient) dialWithRetry(target string, dialOptions []grpc.DialOption, retryDial bool) (*grpc.ClientConn, daemon.StartedServiceClient, error) { +func (c *CommandClient) dialWithRetry(ctx context.Context, target string, dialOptions []grpc.DialOption, retryDial bool) (*grpc.ClientConn, daemon.StartedServiceClient, error) { if !retryDial { connection, err := grpc.NewClient(target, dialOptions...) if err != nil { return nil, nil, E.Cause(err, "create command client") } client := daemon.NewStartedServiceClient(connection) - ctx, cancel := context.WithTimeout(context.Background(), commandClientProbeTimeout) - _, err = client.GetStartedAt(ctx, &emptypb.Empty{}, grpc.WaitForReady(false)) + probeCtx, cancel := context.WithTimeout(ctx, commandClientProbeTimeout) + _, err = client.GetStartedAt(probeCtx, &emptypb.Empty{}, grpc.WaitForReady(false)) cancel() if err != nil { connection.Close() @@ -205,18 +214,25 @@ func (c *CommandClient) dialWithRetry(target string, dialOptions []grpc.DialOpti connection, err = grpc.NewClient(target, dialOptions...) if err != nil { lastError = err - time.Sleep(commandClientDialDelay(attempt)) + select { + case <-ctx.Done(): + return nil, nil, ctx.Err() + case <-time.After(commandClientDialDelay(attempt)): + } continue } client = daemon.NewStartedServiceClient(connection) } - ctx, cancel := context.WithTimeout(context.Background(), commandClientDialDelay(attempt)) - _, err := client.GetStartedAt(ctx, &emptypb.Empty{}, grpc.WaitForReady(true)) + probeCtx, cancel := context.WithTimeout(ctx, commandClientDialDelay(attempt)) + _, err := client.GetStartedAt(probeCtx, &emptypb.Empty{}, grpc.WaitForReady(true)) cancel() if err == nil { return connection, client, nil } lastError = err + if ctx.Err() != nil { + break + } } if connection != nil { @@ -225,15 +241,15 @@ func (c *CommandClient) dialWithRetry(target string, dialOptions []grpc.DialOpti return nil, nil, E.Cause(lastError, "probe command server") } -func (c *CommandClient) dialRemote() (*grpc.ClientConn, daemon.StartedServiceClient, error) { +func (c *CommandClient) dialRemote(ctx context.Context) (*grpc.ClientConn, daemon.StartedServiceClient, error) { connection, err := grpc.NewClient(c.remote.target, c.remote.dialOptions...) if err != nil { return nil, nil, E.Cause(err, "create remote command client") } client := daemon.NewStartedServiceClient(connection) - ctx, cancel := context.WithTimeout(context.Background(), commandClientRemoteProbeTimeout) + probeCtx, cancel := context.WithTimeout(ctx, commandClientRemoteProbeTimeout) defer cancel() - _, err = client.GetStartedAt(ctx, &emptypb.Empty{}) + _, err = client.GetStartedAt(probeCtx, &emptypb.Empty{}) if err != nil { connection.Close() return nil, nil, E.Cause(err, "connect to remote server") @@ -242,66 +258,81 @@ func (c *CommandClient) dialRemote() (*grpc.ClientConn, daemon.StartedServiceCli } func (c *CommandClient) Connect() error { - c.clientMutex.Lock() - common.Close(common.PtrOrNil(c.grpcConn)) - - connection, client, err := c.establishConnection() - if err != nil { - c.clientMutex.Unlock() - return err - } - c.grpcConn = connection - c.grpcClient = client - c.grpcManagedClient = daemon.NewManagedServiceClient(connection) - c.ctx, c.cancel = context.WithCancel(context.Background()) - c.clientMutex.Unlock() - - c.handler.Connected() - return c.dispatchCommands() + return c.connect(c.establishConnection) } func (c *CommandClient) ConnectWithFD(fd int32) error { - c.clientMutex.Lock() - common.Close(common.PtrOrNil(c.grpcConn)) - networkConnection, err := networkConnectionFromFileDescriptor(fd) if err != nil { - c.clientMutex.Unlock() return err } - connection, client, err := c.dialWithRetry("passthrough:///xpc", localDialOptions(func(ctx context.Context, _ string) (net.Conn, error) { - return networkConnection, nil - }), false) + err = c.connect(func(ctx context.Context) (*grpc.ClientConn, daemon.StartedServiceClient, error) { + return c.dialWithRetry(ctx, "passthrough:///xpc", localDialOptions(func(context.Context, string) (net.Conn, error) { + return networkConnection, nil + }), false) + }) if err != nil { networkConnection.Close() - c.clientMutex.Unlock() + } + return err +} + +func (c *CommandClient) connect(dial func(context.Context) (*grpc.ClientConn, daemon.StartedServiceClient, error)) error { + ctx, cancel := context.WithCancel(context.Background()) + c.clientAccess.Lock() + c.cancel() + previousConnection := c.grpcConn + c.grpcConn = nil + c.grpcClient = nil + c.grpcManagedClient = nil + c.ctx = ctx + c.cancel = cancel + c.clientAccess.Unlock() + common.Close(common.PtrOrNil(previousConnection)) + + c.connectAccess.Lock() + if ctx.Err() != nil { + c.connectAccess.Unlock() + return ctx.Err() + } + c.closeConnection() + connection, client, err := dial(ctx) + if err != nil { + c.connectAccess.Unlock() return err } + c.clientAccess.Lock() + if ctx.Err() != nil { + c.clientAccess.Unlock() + c.connectAccess.Unlock() + connection.Close() + return ctx.Err() + } c.grpcConn = connection c.grpcClient = client c.grpcManagedClient = daemon.NewManagedServiceClient(connection) - c.ctx, c.cancel = context.WithCancel(context.Background()) - c.clientMutex.Unlock() + c.clientAccess.Unlock() + c.connectAccess.Unlock() c.handler.Connected() - return c.dispatchCommands() + return c.dispatchCommands(client, ctx) } -func (c *CommandClient) dispatchCommands() error { +func (c *CommandClient) dispatchCommands(client daemon.StartedServiceClient, ctx context.Context) error { for _, command := range c.options.commands { switch command { case CommandLog: - go c.handleLogStream() + go c.handleLogStream(client, ctx) case CommandStatus: - go c.handleStatusStream() + go c.handleStatusStream(client, ctx) case CommandGroup: - go c.handleGroupStream() + go c.handleGroupStream(client, ctx) case CommandClashMode: - go c.handleClashModeStream() + go c.handleClashModeStream(client, ctx) case CommandConnections: - go c.handleConnectionsStream() + go c.handleConnectionsStream(client, ctx) case CommandOutbounds: - go c.handleOutboundsStream() + go c.handleOutboundsStream(client, ctx) default: return E.New("unknown command: ", command) } @@ -310,51 +341,64 @@ func (c *CommandClient) dispatchCommands() error { } func (c *CommandClient) Disconnect() error { - c.clientMutex.Lock() - defer c.clientMutex.Unlock() - if c.cancel != nil { - c.cancel() - } - return common.Close(common.PtrOrNil(c.grpcConn)) + c.clientAccess.Lock() + c.cancel() + connection := c.grpcConn + c.grpcConn = nil + c.grpcClient = nil + c.grpcManagedClient = nil + c.clientAccess.Unlock() + return common.Close(common.PtrOrNil(connection)) } func (c *CommandClient) getClientForCall() (daemon.StartedServiceClient, context.Context, error) { - c.clientMutex.RLock() + c.clientAccess.RLock() if c.grpcClient != nil { - defer c.clientMutex.RUnlock() + defer c.clientAccess.RUnlock() return c.grpcClient, c.ctx, nil } - c.clientMutex.RUnlock() + ctx := c.ctx + c.clientAccess.RUnlock() - c.clientMutex.Lock() - defer c.clientMutex.Unlock() + c.connectAccess.Lock() + defer c.connectAccess.Unlock() - if c.grpcClient != nil { - return c.grpcClient, c.ctx, nil + c.clientAccess.RLock() + if ctx.Err() != nil { + c.clientAccess.RUnlock() + return nil, nil, ctx.Err() } + if c.grpcClient != nil { + defer c.clientAccess.RUnlock() + return c.grpcClient, ctx, nil + } + c.clientAccess.RUnlock() - connection, client, err := c.establishConnection() + connection, client, err := c.establishConnection(ctx) if err != nil { return nil, nil, E.Cause(err, "get command client") } + c.clientAccess.Lock() + if ctx.Err() != nil { + c.clientAccess.Unlock() + connection.Close() + return nil, nil, ctx.Err() + } c.grpcConn = connection c.grpcClient = client c.grpcManagedClient = daemon.NewManagedServiceClient(connection) - if c.ctx == nil { - c.ctx, c.cancel = context.WithCancel(context.Background()) - } - return c.grpcClient, c.ctx, nil + c.clientAccess.Unlock() + return client, ctx, nil } func (c *CommandClient) closeConnection() { - c.clientMutex.Lock() - defer c.clientMutex.Unlock() - if c.grpcConn != nil { - c.grpcConn.Close() - c.grpcConn = nil - c.grpcClient = nil - c.grpcManagedClient = nil - } + c.clientAccess.Lock() + connection := c.grpcConn + c.grpcConn = nil + c.grpcClient = nil + c.grpcManagedClient = nil + c.clientAccess.Unlock() + common.Close(common.PtrOrNil(connection)) } func callWithResult[T any](c *CommandClient, call func(ctx context.Context, client daemon.StartedServiceClient) (T, error)) (T, error) { @@ -378,9 +422,9 @@ func callManagedWithResult[T any](c *CommandClient, call func(ctx context.Contex if c.standalone { defer c.closeConnection() } - c.clientMutex.RLock() + c.clientAccess.RLock() client := c.grpcManagedClient - c.clientMutex.RUnlock() + c.clientAccess.RUnlock() if client == nil { var zero T return zero, os.ErrClosed @@ -388,14 +432,7 @@ func callManagedWithResult[T any](c *CommandClient, call func(ctx context.Contex return call(ctx, client) } -func (c *CommandClient) getStreamContext() (daemon.StartedServiceClient, context.Context) { - c.clientMutex.RLock() - defer c.clientMutex.RUnlock() - return c.grpcClient, c.ctx -} - -func (c *CommandClient) handleLogStream() { - client, ctx := c.getStreamContext() +func (c *CommandClient) handleLogStream(client daemon.StartedServiceClient, ctx context.Context) { stream, err := client.SubscribeLog(ctx, &emptypb.Empty{}) if err != nil { c.handler.Disconnected(E.Cause(err, "subscribe log").Error()) @@ -427,8 +464,7 @@ func (c *CommandClient) handleLogStream() { } } -func (c *CommandClient) handleStatusStream() { - client, ctx := c.getStreamContext() +func (c *CommandClient) handleStatusStream(client daemon.StartedServiceClient, ctx context.Context) { interval := c.options.StatusInterval stream, err := client.SubscribeStatus(ctx, &daemon.SubscribeStatusRequest{ @@ -449,9 +485,7 @@ func (c *CommandClient) handleStatusStream() { } } -func (c *CommandClient) handleGroupStream() { - client, ctx := c.getStreamContext() - +func (c *CommandClient) handleGroupStream(client daemon.StartedServiceClient, ctx context.Context) { stream, err := client.SubscribeGroups(ctx, &emptypb.Empty{}) if err != nil { c.handler.Disconnected(E.Cause(err, "subscribe groups").Error()) @@ -468,9 +502,7 @@ func (c *CommandClient) handleGroupStream() { } } -func (c *CommandClient) handleClashModeStream() { - client, ctx := c.getStreamContext() - +func (c *CommandClient) handleClashModeStream(client daemon.StartedServiceClient, ctx context.Context) { modeStatus, err := client.GetClashModeStatus(ctx, &emptypb.Empty{}) if err != nil { if status.Code(err) != codes.NotFound { @@ -512,8 +544,7 @@ func (c *CommandClient) handleClashModeStream() { } } -func (c *CommandClient) handleConnectionsStream() { - client, ctx := c.getStreamContext() +func (c *CommandClient) handleConnectionsStream(client daemon.StartedServiceClient, ctx context.Context) { interval := c.options.StatusInterval stream, err := client.SubscribeConnections(ctx, &daemon.SubscribeConnectionsRequest{ @@ -535,9 +566,7 @@ func (c *CommandClient) handleConnectionsStream() { } } -func (c *CommandClient) handleOutboundsStream() { - client, ctx := c.getStreamContext() - +func (c *CommandClient) handleOutboundsStream(client daemon.StartedServiceClient, ctx context.Context) { stream, err := client.SubscribeOutbounds(ctx, &emptypb.Empty{}) if err != nil { c.handler.Disconnected(E.Cause(err, "subscribe outbounds").Error()) diff --git a/experimental/libbox/command_client_remote.go b/experimental/libbox/command_client_remote.go index e8ebceac..e0e7fda0 100644 --- a/experimental/libbox/command_client_remote.go +++ b/experimental/libbox/command_client_remote.go @@ -103,5 +103,10 @@ func NewStandaloneRemoteCommandClient(remoteOptions *RemoteConnectionOptions) (* if err != nil { return nil, err } - return &CommandClient{remote: remote}, nil + ctx, cancel := context.WithCancel(context.Background()) + return &CommandClient{ + remote: remote, + ctx: ctx, + cancel: cancel, + }, nil } From a1f948c33020d5697a770d411b5a1d5efbd64298 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 17 Sep 2026 23:51:52 +0800 Subject: [PATCH 11/66] Fix implicit default DNS server and outbound skipping initialize stage --- adapter/outbound/manager.go | 27 +++++++++++++-------------- dns/transport_manager.go | 21 ++++++++++----------- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/adapter/outbound/manager.go b/adapter/outbound/manager.go index 1bbad69e..52c5b6aa 100644 --- a/adapter/outbound/manager.go +++ b/adapter/outbound/manager.go @@ -55,7 +55,7 @@ func (m *Manager) Start(stage adapter.StartStage) error { } m.started = true m.stage = stage - if stage == adapter.StartStateStart { + if stage == adapter.StartStateInitialize { if m.defaultTag != "" && m.defaultOutbound == nil { defaultEndpoint, loaded := m.endpoint.Get(m.defaultTag) if !loaded { @@ -74,20 +74,19 @@ func (m *Manager) Start(stage adapter.StartStage) error { m.outboundByTag[directOutbound.Tag()] = directOutbound m.defaultOutbound = directOutbound } - outbounds := m.outbounds - m.access.Unlock() + } + outbounds := m.outbounds + m.access.Unlock() + if stage == adapter.StartStateStart { return m.startOutbounds(append(outbounds, common.Map(m.endpoint.Endpoints(), func(it adapter.Endpoint) adapter.Outbound { return it })...)) - } else { - outbounds := m.outbounds - m.access.Unlock() - for _, outbound := range outbounds { - name := "outbound/" + outbound.Type() + "[" + outbound.Tag() + "]" - done := adapter.LogElapsed(m.logger, stage, " ", name) - err := adapter.LegacyStart(outbound, stage) - done() - if err != nil { - return E.Cause(err, stage, " ", name) - } + } + for _, outbound := range outbounds { + name := "outbound/" + outbound.Type() + "[" + outbound.Tag() + "]" + done := adapter.LogElapsed(m.logger, stage, " ", name) + err := adapter.LegacyStart(outbound, stage) + done() + if err != nil { + return E.Cause(err, stage, " ", name) } } return nil diff --git a/dns/transport_manager.go b/dns/transport_manager.go index e289ccea..873cae82 100644 --- a/dns/transport_manager.go +++ b/dns/transport_manager.go @@ -56,7 +56,7 @@ func (m *TransportManager) Start(stage adapter.StartStage) error { } m.started = true m.stage = stage - if stage == adapter.StartStateStart { + if stage == adapter.StartStateInitialize { if m.defaultTag != "" && m.defaultTransport == nil { m.access.Unlock() return E.New("default DNS server not found: ", m.defaultTag) @@ -71,17 +71,16 @@ func (m *TransportManager) Start(stage adapter.StartStage) error { m.transportByTag[defaultTransport.Tag()] = defaultTransport m.defaultTransport = defaultTransport } - transports := m.transports - m.access.Unlock() + } + transports := m.transports + m.access.Unlock() + if stage == adapter.StartStateStart { return m.startTransports(transports) - } else { - transports := m.transports - m.access.Unlock() - for _, outbound := range transports { - err := adapter.LegacyStart(outbound, stage) - if err != nil { - return E.Cause(err, stage, " dns/", outbound.Type(), "[", outbound.Tag(), "]") - } + } + for _, transport := range transports { + err := adapter.LegacyStart(transport, stage) + if err != nil { + return E.Cause(err, stage, " dns/", transport.Type(), "[", transport.Tag(), "]") } } return nil From 9cf7a7a28a3d0162eb9f04b76e8efd662a453447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 19 Sep 2026 15:48:02 +0800 Subject: [PATCH 12/66] Use xcode-27 runner image for Apple clients --- .github/workflows/build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d8d61601..15d115b9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1214,7 +1214,7 @@ jobs: path: 'dist' build_apple_library: name: Build Apple library (${{ matrix.artifact }}) - runs-on: ${{ matrix.build && 'macos-26' || 'ubuntu-latest' }} + runs-on: ${{ matrix.build && 'xcode-27' || 'ubuntu-latest' }} if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'Apple-release' || inputs.build == 'iOS' || inputs.build == 'macOS' || inputs.build == 'tvOS' || inputs.build == 'macOS-standalone' || inputs.build == 'iOS-jailbreak' needs: - calculate_version @@ -1285,7 +1285,7 @@ jobs: compression-level: 1 build_apple: name: Build Apple release clients - runs-on: ${{ matrix.if && 'macos-26' || 'ubuntu-latest' }} + runs-on: ${{ matrix.if && 'xcode-27' || 'ubuntu-latest' }} if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple-release' || inputs.build == 'macOS-standalone' || inputs.build == 'iOS-jailbreak' needs: - calculate_version @@ -1616,7 +1616,7 @@ jobs: path: 'dist' build_apple_app_store: name: Build Apple App Store clients - runs-on: ${{ matrix.if && 'macos-26' || 'ubuntu-latest' }} + runs-on: ${{ matrix.if && 'xcode-27' || 'ubuntu-latest' }} if: github.event_name == 'workflow_dispatch' && (inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'iOS' || inputs.build == 'macOS' || inputs.build == 'tvOS') needs: - calculate_version From 9be5fcf4e165d369006dbc9908202ab24fc1cfc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 19 Sep 2026 22:15:13 +0800 Subject: [PATCH 13/66] Fix dial contexts cancelled while connections are still in use --- common/dialer/tfo.go | 15 ++++- common/httpclient/http3_transport.go | 83 +++++++++++++++---------- protocol/cloudflare/inbound.go | 93 ++++++++++++++++++++++++++-- protocol/group/urltest.go | 3 + route/network.go | 6 +- transport/v2raygrpc/client.go | 11 +++- transport/v2raygrpclite/client.go | 28 ++++++++- transport/v2raygrpclite/conn.go | 11 +++- transport/v2rayhttp/client.go | 21 ++++++- transport/v2rayhttp/conn.go | 10 ++- 10 files changed, 229 insertions(+), 52 deletions(-) diff --git a/common/dialer/tfo.go b/common/dialer/tfo.go index 90b7b65f..fec2aaf2 100644 --- a/common/dialer/tfo.go +++ b/common/dialer/tfo.go @@ -20,6 +20,7 @@ import ( type slowOpenConn struct { dialer *tfo.Dialer ctx context.Context + cancel context.CancelFunc network string destination M.Socksaddr conn atomic.Pointer[net.TCPConn] @@ -39,9 +40,11 @@ func DialSlowContext(dialer *tfo.Dialer, ctx context.Context, network string, de return dialer.Dialer.DialContext(ctx, network, destination.AddrString()) } } + dialCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) return &slowOpenConn{ dialer: dialer, - ctx: ctx, + ctx: dialCtx, + cancel: cancel, network: network, destination: destination, create: make(chan struct{}), @@ -86,7 +89,14 @@ func (c *slowOpenConn) Write(b []byte) (n int, err error) { if err != nil { c.err = err } else { - c.conn.Store(conn.(*net.TCPConn)) + select { + case <-c.done: + conn.Close() + err = os.ErrClosed + c.err = err + default: + c.conn.Store(conn.(*net.TCPConn)) + } } n = len(b) close(c.create) @@ -96,6 +106,7 @@ func (c *slowOpenConn) Write(b []byte) (n int, err error) { func (c *slowOpenConn) Close() error { c.closeOnce.Do(func() { close(c.done) + c.cancel() conn := c.conn.Load() if conn != nil { conn.Close() diff --git a/common/httpclient/http3_transport.go b/common/httpclient/http3_transport.go index 7b94560c..e312e519 100644 --- a/common/httpclient/http3_transport.go +++ b/common/httpclient/http3_transport.go @@ -6,6 +6,7 @@ import ( "context" stdTLS "crypto/tls" "errors" + "io" "net/http" "sync" "time" @@ -172,41 +173,49 @@ func (t *http3FallbackTransport) roundTripHTTP3(request *http.Request) (*http.Re } func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request, authority string) (*http.Response, error) { - ctx, cancel := context.WithCancel(request.Context()) - defer cancel() type result struct { response *http.Response err error h3 bool } - results := make(chan result, 2) - startRoundTrip := func(request *http.Request, useH3 bool) { - request = request.WithContext(ctx) - var ( - response *http.Response - err error - ) - if useH3 { - response, err = t.h3Transport.RoundTrip(request) - } else { - response, err = t.h2FallbackRoundTrip(request) - } - results <- result{response: response, err: err, h3: useH3} + var ( + results = make(chan result, 2) + cancels []context.CancelFunc + received int + ) + startRoundTrip := func(useH3 bool) { + ctx, cancel := context.WithCancel(request.Context()) + cancels = append(cancels, cancel) + raceRequest := cloneRequestForRetry(request).WithContext(ctx) + go func() { + var ( + response *http.Response + err error + ) + if useH3 { + response, err = t.h3Transport.RoundTrip(raceRequest) + } else { + response, err = t.h2FallbackRoundTrip(raceRequest) + } + results <- result{response: response, err: err, h3: useH3} + }() } - goroutines := 1 - received := 0 - drainRemaining := func() { - cancel() - for range goroutines - received { + finish := func(winner int) { + for index, cancel := range cancels { + if index != winner { + cancel() + } + } + for range len(cancels) - received { go func() { loser := <-results - if loser.response != nil && loser.response.Body != nil { + if loser.response != nil { loser.response.Body.Close() } }() } } - go startRoundTrip(cloneRequestForRetry(request), true) + startRoundTrip(true) timer := time.NewTimer(t.fallbackDelay) defer timer.Stop() var ( @@ -216,39 +225,40 @@ func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request, autho for { select { case <-timer.C: - if goroutines == 1 { - goroutines++ - go startRoundTrip(cloneRequestForRetry(request), false) + if len(cancels) == 1 { + startRoundTrip(false) } case raceResult := <-results: received++ if raceResult.err == nil { + winner := 1 if raceResult.h3 { + winner = 0 t.clearH3Broken(authority) } - drainRemaining() + finish(winner) + raceResult.response.Body = &cancelOnCloseBody{ReadCloser: raceResult.response.Body, cancel: cancels[winner]} return raceResult.response, nil } if raceResult.h3 { t.markH3Broken(authority) h3Err = raceResult.err - if goroutines == 1 { - goroutines++ + if len(cancels) == 1 { if !timer.Stop() { select { case <-timer.C: default: } } - go startRoundTrip(cloneRequestForRetry(request), false) + startRoundTrip(false) } } else { fallbackErr = raceResult.err } - if received < goroutines { + if received < len(cancels) { continue } - drainRemaining() + finish(-1) switch { case h3Err != nil && fallbackErr != nil: return nil, E.Errors(h3Err, fallbackErr) @@ -261,6 +271,17 @@ func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request, autho } } +type cancelOnCloseBody struct { + io.ReadCloser + cancel context.CancelFunc +} + +func (b *cancelOnCloseBody) Close() error { + err := b.ReadCloser.Close() + b.cancel() + return err +} + func (t *http3FallbackTransport) h2FallbackRoundTrip(request *http.Request) (*http.Response, error) { if fallback, isFallback := t.h2Fallback.(*http2FallbackTransport); isFallback { return fallback.roundTrip(request, true) diff --git a/protocol/cloudflare/inbound.go b/protocol/cloudflare/inbound.go index 6902b006..e190d37e 100644 --- a/protocol/cloudflare/inbound.go +++ b/protocol/cloudflare/inbound.go @@ -53,7 +53,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo service, err := cloudflared.NewService(cloudflared.ServiceOptions{ Logger: logger, - ConnectionDialer: &routerDialer{router: router, tag: tag}, + ConnectionDialer: &routerDialer{ctx: ctx, router: router, tag: tag}, ControlDialer: controlDialer, TunnelDialer: tunnelDialer, ControlResolver: controlResolver, @@ -101,10 +101,82 @@ func (i *Inbound) Close() error { } type routerDialer struct { + ctx context.Context router adapter.Router tag string } +// cloudflared cancels the dial context as soon as DialContext/ListenPacket returns +// (router_pipe.go dialRouterTCPWithMetadata, origin_dial.go dialWarpPacketConnection), while the +// routing and the outbound dial behind the pipe are still in progress, so only its deadline is +// honoured, and only until the outbound handshake is reported. +func (d *routerDialer) routeContext(ctx context.Context) (context.Context, func(), func()) { + routeCtx, cancel := context.WithCancelCause(context.WithoutCancel(ctx)) + stopShutdown := context.AfterFunc(d.ctx, func() { + cancel(context.Cause(d.ctx)) + }) + var connectTimeout *time.Timer + if deadline, hasDeadline := ctx.Deadline(); hasDeadline { + connectTimeout = time.AfterFunc(time.Until(deadline), func() { + cancel(context.DeadlineExceeded) + }) + } + connected := func() { + if connectTimeout != nil { + connectTimeout.Stop() + } + } + closed := func() { + connected() + stopShutdown() + } + return routeCtx, connected, closed +} + +type routedConn struct { + net.Conn + connected func() +} + +func (c *routedConn) HandshakeSuccess() error { + c.connected() + return nil +} + +func (c *routedConn) ReaderReplaceable() bool { + return true +} + +func (c *routedConn) WriterReplaceable() bool { + return true +} + +func (c *routedConn) Upstream() any { + return c.Conn +} + +type routedPacketConn struct { + N.PacketConn + connected func() +} + +func (c *routedPacketConn) HandshakeSuccess() error { + c.connected() + return nil +} + +func (c *routedPacketConn) ReaderReplaceable() bool { + return true +} + +func (c *routedPacketConn) WriterReplaceable() bool { + return true +} + +func (c *routedPacketConn) Upstream() any { + return c.PacketConn +} + func (d *routerDialer) newMetadata(network string, destination M.Socksaddr) adapter.InboundContext { return adapter.InboundContext{ Inbound: d.tag, @@ -116,7 +188,9 @@ func (d *routerDialer) newMetadata(network string, destination M.Socksaddr) adap func (d *routerDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { input, output := pipe.Pipe() - go d.router.RouteConnectionEx(ctx, output, d.newMetadata(N.NetworkTCP, destination), N.OnceClose(func(it error) { + routeCtx, connected, closed := d.routeContext(ctx) + go d.router.RouteConnectionEx(routeCtx, &routedConn{Conn: output, connected: connected}, d.newMetadata(N.NetworkTCP, destination), N.OnceClose(func(it error) { + closed() input.Close() })) return input, nil @@ -124,8 +198,10 @@ func (d *routerDialer) DialContext(ctx context.Context, network string, destinat func (d *routerDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { input, output := pipe.Pipe() - routerConn := bufio.NewUnbindPacketConn(output) - go d.router.RoutePacketConnectionEx(ctx, routerConn, d.newMetadata(N.NetworkUDP, destination), N.OnceClose(func(it error) { + routeCtx, connected, closed := d.routeContext(ctx) + routerConn := &routedPacketConn{PacketConn: bufio.NewUnbindPacketConn(output), connected: connected} + go d.router.RoutePacketConnectionEx(routeCtx, routerConn, d.newMetadata(N.NetworkUDP, destination), N.OnceClose(func(it error) { + closed() input.Close() })) return bufio.NewUnbindPacketConn(input), nil @@ -178,3 +254,12 @@ func (h *icmpRouterHandler) RouteICMPFlow(source netip.Addr, destination netip.A return nil, E.New("no direct route") } } + +var ( + _ N.HandshakeSuccess = (*routedConn)(nil) + _ N.ReaderWithUpstream = (*routedConn)(nil) + _ N.WriterWithUpstream = (*routedConn)(nil) + _ N.HandshakeSuccess = (*routedPacketConn)(nil) + _ N.ReaderWithUpstream = (*routedPacketConn)(nil) + _ N.WriterWithUpstream = (*routedPacketConn)(nil) +) diff --git a/protocol/group/urltest.go b/protocol/group/urltest.go index ba3197fb..1bb6b113 100644 --- a/protocol/group/urltest.go +++ b/protocol/group/urltest.go @@ -464,6 +464,9 @@ func (b *urlTestBatch) test(outbounds []adapter.Outbound, link string, interval testResult.err = testCtx.Err() } if testResult.err != nil { + if b.ctx.Err() != nil { + return nil, nil + } b.logger.Debug("outbound ", tag, " unavailable: ", testResult.err) b.history.DeleteURLTestHistory(tag) } else { diff --git a/route/network.go b/route/network.go index ae950e44..9d74fed3 100644 --- a/route/network.go +++ b/route/network.go @@ -534,10 +534,7 @@ func (r *NetworkManager) notifyInterfaceUpdate(defaultInterface *control.Interfa if previousCancel != nil { previousCancel() } - go func() { - defer updateCancel() - r.updateInterface(updateContext, defaultInterface) - }() + go r.updateInterface(updateContext, defaultInterface) } func (r *NetworkManager) updateInterface(ctx context.Context, defaultInterface *control.Interface) { @@ -606,7 +603,6 @@ func (r *NetworkManager) notifyWindowsPowerEvent(event int) { previousCancel() } go func() { - defer updateCancel() r.resetRunAccess.Lock() defer r.resetRunAccess.Unlock() if updateContext.Err() != nil { diff --git a/transport/v2raygrpc/client.go b/transport/v2raygrpc/client.go index 7c5e87ca..293373ae 100644 --- a/transport/v2raygrpc/client.go +++ b/transport/v2raygrpc/client.go @@ -102,12 +102,19 @@ func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { return nil, err } client := NewGunServiceClient(clientConn).(GunServiceCustomNameClient) - ctx, cancel := context.WithCancelCause(ctx) - stream, err := client.TunCustomName(ctx, c.serviceName) + streamCtx, cancel := context.WithCancelCause(context.WithoutCancel(ctx)) + stopPropagation := context.AfterFunc(ctx, func() { + cancel(context.Cause(ctx)) + }) + stream, err := client.TunCustomName(streamCtx, c.serviceName) if err != nil { + stopPropagation() cancel(err) return nil, err } + if !stopPropagation() { + return nil, context.Cause(ctx) + } return NewGRPCConn(stream, cancel), nil } diff --git a/transport/v2raygrpclite/client.go b/transport/v2raygrpclite/client.go index b2aab911..7a42cf41 100644 --- a/transport/v2raygrpclite/client.go +++ b/transport/v2raygrpclite/client.go @@ -5,11 +5,15 @@ import ( "io" "net" "net/http" + "net/http/httptrace" "net/url" + "os" + "sync/atomic" "time" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/transport/v2rayhttp" E "github.com/sagernet/sing/common/exceptions" @@ -79,6 +83,7 @@ func NewClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, opt func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { pipeInReader, pipeInWriter := io.Pipe() + requestCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) request := &http.Request{ Method: http.MethodPost, Body: pipeInReader, @@ -86,11 +91,30 @@ func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { Header: defaultClientHeader, Host: c.host, } - request = request.WithContext(ctx) - conn := newLateGunConn(pipeInWriter) + conn := newLateGunConn(pipeInWriter, cancel) + handshakeTimeout := C.TCPTimeout + if deadline, hasDeadline := ctx.Deadline(); hasDeadline { + handshakeTimeout = time.Until(deadline) + } + var handshakeTimedOut atomic.Bool + handshakeTimer := time.AfterFunc(handshakeTimeout, func() { + handshakeTimedOut.Store(true) + cancel() + }) + // gRPC servers send the response headers together with the first message, so RoundTrip + // returning does not mark the stream as established; the request headers being written does. + request = request.WithContext(httptrace.WithClientTrace(requestCtx, &httptrace.ClientTrace{ + WroteHeaders: func() { + handshakeTimer.Stop() + }, + })) go func() { response, err := c.transport.RoundTrip(request) + handshakeTimer.Stop() if err != nil { + if handshakeTimedOut.Load() { + err = os.ErrDeadlineExceeded + } conn.setup(nil, err) } else if response.StatusCode != 200 { response.Body.Close() diff --git a/transport/v2raygrpclite/conn.go b/transport/v2raygrpclite/conn.go index 5feafbb6..b1f45222 100644 --- a/transport/v2raygrpclite/conn.go +++ b/transport/v2raygrpclite/conn.go @@ -2,6 +2,7 @@ package v2raygrpclite import ( std_bufio "bufio" + "context" "encoding/binary" "io" "net" @@ -27,6 +28,7 @@ type GunConn struct { flusher http.Flusher create chan struct{} err error + cancel context.CancelFunc readRemaining int } @@ -39,10 +41,11 @@ func newGunConn(reader io.Reader, writer io.Writer, flusher http.Flusher) *GunCo } } -func newLateGunConn(writer io.Writer) *GunConn { +func newLateGunConn(writer io.Writer, cancel context.CancelFunc) *GunConn { return &GunConn{ create: make(chan struct{}), writer: writer, + cancel: cancel, } } @@ -141,7 +144,11 @@ func (c *GunConn) FrontHeadroom() int { } func (c *GunConn) Close() error { - return common.Close(c.rawReader, c.writer) + err := common.Close(c.rawReader, c.writer) + if c.cancel != nil { + c.cancel() + } + return err } func (c *GunConn) LocalAddr() net.Addr { diff --git a/transport/v2rayhttp/client.go b/transport/v2rayhttp/client.go index 6c327cd6..fdd9210c 100644 --- a/transport/v2rayhttp/client.go +++ b/transport/v2rayhttp/client.go @@ -7,11 +7,14 @@ import ( "net" "net/http" "net/url" + "os" "strings" + "sync/atomic" "time" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" E "github.com/sagernet/sing/common/exceptions" M "github.com/sagernet/sing/common/metadata" @@ -120,13 +123,14 @@ func (c *Client) dialHTTP(ctx context.Context) (net.Conn, error) { func (c *Client) dialHTTP2(ctx context.Context) (net.Conn, error) { pipeInReader, pipeInWriter := io.Pipe() + requestCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) request := &http.Request{ Method: c.method, Body: pipeInReader, URL: &c.requestURL, Header: c.headers.Clone(), } - request = request.WithContext(ctx) + request = request.WithContext(requestCtx) switch hostLen := len(c.host); hostLen { case 0: // https://github.com/v2fly/v2ray-core/blob/master/transport/internet/http/config.go#L13 @@ -136,10 +140,23 @@ func (c *Client) dialHTTP2(ctx context.Context) (net.Conn, error) { default: request.Host = c.host[rand.Intn(hostLen)] } - conn := NewLateHTTPConn(pipeInWriter) + conn := NewLateHTTPConn(pipeInWriter, cancel) + handshakeTimeout := C.TCPTimeout + if deadline, hasDeadline := ctx.Deadline(); hasDeadline { + handshakeTimeout = time.Until(deadline) + } + var handshakeTimedOut atomic.Bool + handshakeTimer := time.AfterFunc(handshakeTimeout, func() { + handshakeTimedOut.Store(true) + cancel() + }) go func() { response, err := c.transport.RoundTrip(request) + handshakeTimer.Stop() if err != nil { + if handshakeTimedOut.Load() { + err = os.ErrDeadlineExceeded + } conn.Setup(nil, err) } else if response.StatusCode != 200 { response.Body.Close() diff --git a/transport/v2rayhttp/conn.go b/transport/v2rayhttp/conn.go index b339a753..5f5cadb9 100644 --- a/transport/v2rayhttp/conn.go +++ b/transport/v2rayhttp/conn.go @@ -134,6 +134,7 @@ type HTTP2Conn struct { writer io.Writer create chan struct{} err error + cancel context.CancelFunc } func NewHTTPConn(reader io.Reader, writer io.Writer) HTTP2Conn { @@ -143,10 +144,11 @@ func NewHTTPConn(reader io.Reader, writer io.Writer) HTTP2Conn { } } -func NewLateHTTPConn(writer io.Writer) *HTTP2Conn { +func NewLateHTTPConn(writer io.Writer, cancel context.CancelFunc) *HTTP2Conn { return &HTTP2Conn{ create: make(chan struct{}), writer: writer, + cancel: cancel, } } @@ -173,7 +175,11 @@ func (c *HTTP2Conn) Write(b []byte) (n int, err error) { } func (c *HTTP2Conn) Close() error { - return common.Close(c.reader, c.writer) + err := common.Close(c.reader, c.writer) + if c.cancel != nil { + c.cancel() + } + return err } func (c *HTTP2Conn) LocalAddr() net.Addr { From 18b0317018bdc823ecd9c424b247a19e631e852d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 19 Sep 2026 22:23:22 +0800 Subject: [PATCH 14/66] Fix HTTP/2 stream errors leaking into nested readers and transport data races --- go.mod | 4 ++-- go.sum | 8 ++++---- transport/v2raygrpclite/conn.go | 14 ++++++++++++-- transport/v2rayhttp/client.go | 14 ++++++++------ transport/v2rayhttp/conn.go | 16 +++++++++++++--- 5 files changed, 39 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index 666a03e4..b50e48e8 100644 --- a/go.mod +++ b/go.mod @@ -45,9 +45,9 @@ require ( github.com/sagernet/netlink v0.0.0-20260814022025-64455d367bbf github.com/sagernet/nftables v0.3.0-mod.4 github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 - github.com/sagernet/sing v0.9.4 + github.com/sagernet/sing v0.9.5 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 - github.com/sagernet/sing-mux v0.3.6 + github.com/sagernet/sing-mux v0.3.8 github.com/sagernet/sing-openconnect v0.0.0-20260810065514-53aa8058f8df github.com/sagernet/sing-openvpn v0.0.0-20260729104525-103eb5fe5eb6 github.com/sagernet/sing-quic v0.7.0 diff --git a/go.sum b/go.sum index 95579cb2..c34178eb 100644 --- a/go.sum +++ b/go.sum @@ -318,12 +318,12 @@ github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4IC github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 h1:sW2O+DoNF+my1PGlSZ3zG2N+fdxLR3DC0l4sXCM0lBE= github.com/sagernet/quic-go v0.61.0-sing-box-mod.7/go.mod h1:hmLC8GJPp+BrpBgrgJnacvI4fZGIhasuy1ROJJ8GR4E= -github.com/sagernet/sing v0.9.4 h1:nIdH+Yvy4NzTezB+c3z/9of1jLRggnrtMtb3uMOIaNM= -github.com/sagernet/sing v0.9.4/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= +github.com/sagernet/sing v0.9.5 h1:BHaElRqhHhkH747xIkxgsqgRJxkIiuAlpnfwiJnu2aY= +github.com/sagernet/sing v0.9.5/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6++yIa8XlDhxPkpR4p+7RUHVY2KTP9CPIGnWmOlO8= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= -github.com/sagernet/sing-mux v0.3.6 h1:ix+CPhZsR9XIAmEVLIUdz+itDzXs1T6iKO8eF/XOVOQ= -github.com/sagernet/sing-mux v0.3.6/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= +github.com/sagernet/sing-mux v0.3.8 h1:mM0TLUkr36aEhgUHi9Qh60qcAbD2fZcmnJ30937xxrY= +github.com/sagernet/sing-mux v0.3.8/go.mod h1:jricpwCOndDsTEM1TM3piJvhL/2Mm7OhLkm+JadJJ1Q= github.com/sagernet/sing-openconnect v0.0.0-20260810065514-53aa8058f8df h1:ogyC7KPTWueN7kXFhKSdHeS5/t/zBjQgAOpWjqnrMos= github.com/sagernet/sing-openconnect v0.0.0-20260810065514-53aa8058f8df/go.mod h1:4AKZLVcvY3r54UaK2Gbnm7aN8pOwdLz+y4EP0QFZ5Eg= github.com/sagernet/sing-openvpn v0.0.0-20260729104525-103eb5fe5eb6 h1:ZAvhkor0prJ8KEX9EmTEH+gJ1WlA3ffjS8GZT6KKq9c= diff --git a/transport/v2raygrpclite/conn.go b/transport/v2raygrpclite/conn.go index b1f45222..896183b9 100644 --- a/transport/v2raygrpclite/conn.go +++ b/transport/v2raygrpclite/conn.go @@ -64,7 +64,7 @@ func (c *GunConn) Read(b []byte) (n int, err error) { } func (c *GunConn) read(b []byte) (n int, err error) { - if c.reader == nil { + if c.create != nil { <-c.create if c.err != nil { return 0, c.err @@ -144,7 +144,17 @@ func (c *GunConn) FrontHeadroom() int { } func (c *GunConn) Close() error { - err := common.Close(c.rawReader, c.writer) + var reader io.Reader + if c.create != nil { + select { + case <-c.create: + reader = c.rawReader + default: + } + } else { + reader = c.rawReader + } + err := common.Close(reader, c.writer) if c.cancel != nil { c.cancel() } diff --git a/transport/v2rayhttp/client.go b/transport/v2rayhttp/client.go index fdd9210c..bba65960 100644 --- a/transport/v2rayhttp/client.go +++ b/transport/v2rayhttp/client.go @@ -16,6 +16,7 @@ import ( "github.com/sagernet/sing-box/common/tls" 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" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" @@ -30,7 +31,7 @@ type Client struct { ctx context.Context dialer N.Dialer serverAddr M.Socksaddr - transport http.RoundTripper + transport common.TypedValue[http.RoundTripper] http2 bool requestURL url.URL host []string @@ -77,7 +78,7 @@ func NewClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, opt if !strings.HasPrefix(requestURL.Path, "/") { requestURL.Path = "/" + requestURL.Path } - return &Client{ + client := &Client{ ctx: ctx, dialer: dialer, serverAddr: serverAddr, @@ -85,9 +86,10 @@ func NewClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, opt host: options.Host, method: options.Method, headers: options.Headers.Build(), - transport: transport, http2: tlsConfig != nil, - }, nil + } + client.transport.Store(transport) + return client, nil } func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { @@ -151,7 +153,7 @@ func (c *Client) dialHTTP2(ctx context.Context) (net.Conn, error) { cancel() }) go func() { - response, err := c.transport.RoundTrip(request) + response, err := c.transport.Load().RoundTrip(request) handshakeTimer.Stop() if err != nil { if handshakeTimedOut.Load() { @@ -169,6 +171,6 @@ func (c *Client) dialHTTP2(ctx context.Context) (net.Conn, error) { } func (c *Client) Close() error { - c.transport = ResetTransport(c.transport) + c.transport.Store(ResetTransport(c.transport.Load())) return nil } diff --git a/transport/v2rayhttp/conn.go b/transport/v2rayhttp/conn.go index 5f5cadb9..758eda7f 100644 --- a/transport/v2rayhttp/conn.go +++ b/transport/v2rayhttp/conn.go @@ -159,10 +159,10 @@ func (c *HTTP2Conn) Setup(reader io.Reader, err error) { } func (c *HTTP2Conn) Read(b []byte) (n int, err error) { - if c.reader == nil { + if c.create != nil { <-c.create if c.err != nil { - return 0, c.err + return 0, baderror.WrapH2(c.err) } } n, err = c.reader.Read(b) @@ -175,7 +175,17 @@ func (c *HTTP2Conn) Write(b []byte) (n int, err error) { } func (c *HTTP2Conn) Close() error { - err := common.Close(c.reader, c.writer) + var reader io.Reader + if c.create != nil { + select { + case <-c.create: + reader = c.reader + default: + } + } else { + reader = c.reader + } + err := common.Close(reader, c.writer) if c.cancel != nil { c.cancel() } From 3f0958bf582343add7a3e16243b0ef0bd2050497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 19 Sep 2026 22:23:24 +0800 Subject: [PATCH 15/66] Fix read loops spinning on persistent errors --- common/listener/listener_tcp.go | 24 +++++++++++++++++------- go.mod | 2 +- go.sum | 4 ++-- protocol/bridge/backend_linux.go | 13 +++++++++---- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/common/listener/listener_tcp.go b/common/listener/listener_tcp.go index c0e7aad9..50643ea4 100644 --- a/common/listener/listener_tcp.go +++ b/common/listener/listener_tcp.go @@ -84,22 +84,32 @@ func (l *Listener) ListenTCP() (net.Listener, error) { func (l *Listener) loopTCPIn() { tcpListener := l.tcpListener - var metadata adapter.InboundContext + var ( + metadata adapter.InboundContext + acceptDelay time.Duration + ) for { conn, err := tcpListener.Accept() if err != nil { - //nolint:staticcheck - if netError, isNetError := err.(net.Error); isNetError && netError.Temporary() { - l.logger.Error(err) - continue - } if l.shutdown.Load() && E.IsClosed(err) { return } + //nolint:staticcheck + if netError, isNetError := err.(net.Error); isNetError && netError.Temporary() { + if acceptDelay == 0 { + acceptDelay = 5 * time.Millisecond + } else { + acceptDelay = min(2*acceptDelay, time.Second) + } + l.logger.Error(err, ", retrying in ", acceptDelay) + time.Sleep(acceptDelay) + continue + } l.tcpListener.Close() l.logger.Error("tcp listener closed: ", err) - continue + return } + acceptDelay = 0 //nolint:staticcheck metadata.InboundDetour = l.listenOptions.Detour metadata.Source = M.SocksaddrFromNet(conn.RemoteAddr()).Unwrap() diff --git a/go.mod b/go.mod index b50e48e8..134d4d25 100644 --- a/go.mod +++ b/go.mod @@ -55,7 +55,7 @@ require ( github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1 github.com/sagernet/sing-snell v0.0.0-20260829071736-20f2eaec77c3 - github.com/sagernet/sing-tun v0.9.3 + github.com/sagernet/sing-tun v0.9.5 github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca github.com/sagernet/sing-vmess v0.2.8 github.com/sagernet/smux v1.5.50-sing-box-mod.1 diff --git a/go.sum b/go.sum index c34178eb..72df4759 100644 --- a/go.sum +++ b/go.sum @@ -338,8 +338,8 @@ github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkG github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= github.com/sagernet/sing-snell v0.0.0-20260829071736-20f2eaec77c3 h1:wjEw0lmCSC+oqh7GD4rbMB/dk7+hZ2VopUj4xM5/Jrc= github.com/sagernet/sing-snell v0.0.0-20260829071736-20f2eaec77c3/go.mod h1:et8Lws4f5QbOrY65DmjevHGup3mijJkhswkto6cwciM= -github.com/sagernet/sing-tun v0.9.3 h1:fCXbGezGMUXi00X0MmG3iX5QR91sPApZwzfr0MAVHv4= -github.com/sagernet/sing-tun v0.9.3/go.mod h1:3EgPst7agntRO7D6GOsiZ1l9FoqdLeuWmKT5TnWkmf0= +github.com/sagernet/sing-tun v0.9.5 h1:Kzprh1wjR2WzS+nh0gAPa4xKSqEc6AYG+Wcz1gR4fCg= +github.com/sagernet/sing-tun v0.9.5/go.mod h1:3EgPst7agntRO7D6GOsiZ1l9FoqdLeuWmKT5TnWkmf0= github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca h1:5wA+IE0Fq1CGVLOgSpm0gKKZ03HzugcJ2JyhmDqeX6A= github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca/go.mod h1:ADAZZU85MnM91XNhd2TdZRTaIbA7RjTZuQ1KCHcUbNg= github.com/sagernet/sing-vmess v0.2.8 h1:xd5nnDOMlC76RgrLksS4jlk3eMt3c3CvQY3NsjWPWeI= diff --git a/protocol/bridge/backend_linux.go b/protocol/bridge/backend_linux.go index c617c0bd..8634ccfd 100644 --- a/protocol/bridge/backend_linux.go +++ b/protocol/bridge/backend_linux.go @@ -313,7 +313,10 @@ func (b *backendLinux) batchReadLoop() { sizes := make([]int, batchSize) batch := make([][]byte, 0, batchSize) headroom := -1 - var buffers [][]byte + var ( + buffers [][]byte + readRetry tun.ReadRetry + ) for { b.returnAccess.Lock() returnPaths := b.returnPaths @@ -336,11 +339,13 @@ func (b *backendLinux) batchReadLoop() { return default: } - if E.IsClosed(err) { + b.logger.Debug(E.Cause(err, "bridge tun read")) + if !tun.IsRecoverableReadError(err) { return } - b.logger.Debug(E.Cause(err, "bridge tun read")) - continue + readRetry.Wait(err) + } else { + readRetry.Reset() } if n == 0 || len(returnPaths) == 0 { continue From 7b1d7e1c858594be0f07268c2fffcc246ec37373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 19 Sep 2026 23:22:18 +0800 Subject: [PATCH 16/66] Fix tls_fragment treating empty write as first packet --- common/tlsfragment/conn.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/tlsfragment/conn.go b/common/tlsfragment/conn.go index 040663bd..fb6e5e0b 100644 --- a/common/tlsfragment/conn.go +++ b/common/tlsfragment/conn.go @@ -41,7 +41,7 @@ func NewConn(conn net.Conn, ctx context.Context, splitPacket bool, splitRecord b } func (c *Conn) Write(b []byte) (n int, err error) { - if !c.firstPacketWritten { + if !c.firstPacketWritten && len(b) > 0 { defer func() { c.firstPacketWritten = true }() From fbb74b13e629b7044146352ac29e982171a4f78e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 19 Sep 2026 23:23:39 +0800 Subject: [PATCH 17/66] Fix early handshake not sent through outbound groups --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 134d4d25..2ae8a0ea 100644 --- a/go.mod +++ b/go.mod @@ -45,7 +45,7 @@ require ( github.com/sagernet/netlink v0.0.0-20260814022025-64455d367bbf github.com/sagernet/nftables v0.3.0-mod.4 github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 - github.com/sagernet/sing v0.9.5 + github.com/sagernet/sing v0.9.6-0.20260919152213-438fd16dfb39 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.8 github.com/sagernet/sing-openconnect v0.0.0-20260810065514-53aa8058f8df diff --git a/go.sum b/go.sum index 72df4759..2d2e818b 100644 --- a/go.sum +++ b/go.sum @@ -318,8 +318,8 @@ github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4IC github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 h1:sW2O+DoNF+my1PGlSZ3zG2N+fdxLR3DC0l4sXCM0lBE= github.com/sagernet/quic-go v0.61.0-sing-box-mod.7/go.mod h1:hmLC8GJPp+BrpBgrgJnacvI4fZGIhasuy1ROJJ8GR4E= -github.com/sagernet/sing v0.9.5 h1:BHaElRqhHhkH747xIkxgsqgRJxkIiuAlpnfwiJnu2aY= -github.com/sagernet/sing v0.9.5/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= +github.com/sagernet/sing v0.9.6-0.20260919152213-438fd16dfb39 h1:NQNzEBLUcmxLhYtmVeOL6oF39NvqkTbQEhNxP1BTGoA= +github.com/sagernet/sing v0.9.6-0.20260919152213-438fd16dfb39/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6++yIa8XlDhxPkpR4p+7RUHVY2KTP9CPIGnWmOlO8= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.8 h1:mM0TLUkr36aEhgUHi9Qh60qcAbD2fZcmnJ30937xxrY= From bea01fe80ab9f46c931789c7cb77fad0553341ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 20 Sep 2026 00:09:42 +0800 Subject: [PATCH 18/66] Fix HTTP proxy panic when upstream responds early --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 2ae8a0ea..4332b036 100644 --- a/go.mod +++ b/go.mod @@ -45,7 +45,7 @@ require ( github.com/sagernet/netlink v0.0.0-20260814022025-64455d367bbf github.com/sagernet/nftables v0.3.0-mod.4 github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 - github.com/sagernet/sing v0.9.6-0.20260919152213-438fd16dfb39 + github.com/sagernet/sing v0.9.6-0.20260919160821-f122e8936799 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.8 github.com/sagernet/sing-openconnect v0.0.0-20260810065514-53aa8058f8df diff --git a/go.sum b/go.sum index 2d2e818b..4b045885 100644 --- a/go.sum +++ b/go.sum @@ -318,8 +318,8 @@ github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4IC github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 h1:sW2O+DoNF+my1PGlSZ3zG2N+fdxLR3DC0l4sXCM0lBE= github.com/sagernet/quic-go v0.61.0-sing-box-mod.7/go.mod h1:hmLC8GJPp+BrpBgrgJnacvI4fZGIhasuy1ROJJ8GR4E= -github.com/sagernet/sing v0.9.6-0.20260919152213-438fd16dfb39 h1:NQNzEBLUcmxLhYtmVeOL6oF39NvqkTbQEhNxP1BTGoA= -github.com/sagernet/sing v0.9.6-0.20260919152213-438fd16dfb39/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= +github.com/sagernet/sing v0.9.6-0.20260919160821-f122e8936799 h1:ppEVNvfaAxcwILWccqMvnrQMDWu67r8T21jqcdLJ8c8= +github.com/sagernet/sing v0.9.6-0.20260919160821-f122e8936799/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6++yIa8XlDhxPkpR4p+7RUHVY2KTP9CPIGnWmOlO8= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.8 h1:mM0TLUkr36aEhgUHi9Qh60qcAbD2fZcmnJ30937xxrY= From afb0b30558fa1861de31ffc5ddf236926b921517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 20 Sep 2026 11:20:17 +0800 Subject: [PATCH 19/66] Fix traffic counters on interrupted splice copies --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 4332b036..f118e34b 100644 --- a/go.mod +++ b/go.mod @@ -45,7 +45,7 @@ require ( github.com/sagernet/netlink v0.0.0-20260814022025-64455d367bbf github.com/sagernet/nftables v0.3.0-mod.4 github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 - github.com/sagernet/sing v0.9.6-0.20260919160821-f122e8936799 + github.com/sagernet/sing v0.9.6-0.20260920031521-0f86e07920f7 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.8 github.com/sagernet/sing-openconnect v0.0.0-20260810065514-53aa8058f8df diff --git a/go.sum b/go.sum index 4b045885..fff9c8ee 100644 --- a/go.sum +++ b/go.sum @@ -318,8 +318,8 @@ github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4IC github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 h1:sW2O+DoNF+my1PGlSZ3zG2N+fdxLR3DC0l4sXCM0lBE= github.com/sagernet/quic-go v0.61.0-sing-box-mod.7/go.mod h1:hmLC8GJPp+BrpBgrgJnacvI4fZGIhasuy1ROJJ8GR4E= -github.com/sagernet/sing v0.9.6-0.20260919160821-f122e8936799 h1:ppEVNvfaAxcwILWccqMvnrQMDWu67r8T21jqcdLJ8c8= -github.com/sagernet/sing v0.9.6-0.20260919160821-f122e8936799/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= +github.com/sagernet/sing v0.9.6-0.20260920031521-0f86e07920f7 h1:eoPIaR1ifp1px7zQcoLNXkpNw+lYWbwl+G2Oe5a2Ymg= +github.com/sagernet/sing v0.9.6-0.20260920031521-0f86e07920f7/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6++yIa8XlDhxPkpR4p+7RUHVY2KTP9CPIGnWmOlO8= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.8 h1:mM0TLUkr36aEhgUHi9Qh60qcAbD2fZcmnJ30937xxrY= From 6a7f11fca3fade0783ea615b37f9fcbf15a01460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 20 Sep 2026 12:29:42 +0800 Subject: [PATCH 20/66] Fix JSON comment scanner panic on truncated input --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f118e34b..4cd26fa1 100644 --- a/go.mod +++ b/go.mod @@ -45,7 +45,7 @@ require ( github.com/sagernet/netlink v0.0.0-20260814022025-64455d367bbf github.com/sagernet/nftables v0.3.0-mod.4 github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 - github.com/sagernet/sing v0.9.6-0.20260920031521-0f86e07920f7 + github.com/sagernet/sing v0.9.6-0.20260920042621-643b9d01347d github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.8 github.com/sagernet/sing-openconnect v0.0.0-20260810065514-53aa8058f8df diff --git a/go.sum b/go.sum index fff9c8ee..1d604331 100644 --- a/go.sum +++ b/go.sum @@ -318,8 +318,8 @@ github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4IC github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 h1:sW2O+DoNF+my1PGlSZ3zG2N+fdxLR3DC0l4sXCM0lBE= github.com/sagernet/quic-go v0.61.0-sing-box-mod.7/go.mod h1:hmLC8GJPp+BrpBgrgJnacvI4fZGIhasuy1ROJJ8GR4E= -github.com/sagernet/sing v0.9.6-0.20260920031521-0f86e07920f7 h1:eoPIaR1ifp1px7zQcoLNXkpNw+lYWbwl+G2Oe5a2Ymg= -github.com/sagernet/sing v0.9.6-0.20260920031521-0f86e07920f7/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= +github.com/sagernet/sing v0.9.6-0.20260920042621-643b9d01347d h1:4HL9hWcaBCAyIm4bPWxTtWa4Oz4YcLgSvLcjVIQdpdY= +github.com/sagernet/sing v0.9.6-0.20260920042621-643b9d01347d/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6++yIa8XlDhxPkpR4p+7RUHVY2KTP9CPIGnWmOlO8= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.8 h1:mM0TLUkr36aEhgUHi9Qh60qcAbD2fZcmnJ30937xxrY= From 60f0a0369b1829f02205a1a77246412cf9366645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 20 Sep 2026 17:36:19 +0800 Subject: [PATCH 21/66] Fix half-close not propagating through connection wrappers --- common/badtls/read_wait.go | 4 ++++ common/tls/reality_client.go | 2 +- common/tls/reality_server.go | 2 +- go.mod | 2 +- go.sum | 4 ++-- route/conn.go | 16 ++++++++++------ 6 files changed, 19 insertions(+), 11 deletions(-) diff --git a/common/badtls/read_wait.go b/common/badtls/read_wait.go index 8448b1a2..982f0f17 100644 --- a/common/badtls/read_wait.go +++ b/common/badtls/read_wait.go @@ -80,3 +80,7 @@ func (c *ReadWaitConn) Upstream() any { func (c *ReadWaitConn) ReaderReplaceable() bool { return true } + +func (c *ReadWaitConn) WriterReplaceable() bool { + return true +} diff --git a/common/tls/reality_client.go b/common/tls/reality_client.go index af64ef6f..13f88e38 100644 --- a/common/tls/reality_client.go +++ b/common/tls/reality_client.go @@ -343,5 +343,5 @@ func (c *realityClientConnWrapper) ReaderReplaceable() bool { } func (c *realityClientConnWrapper) WriterReplaceable() bool { - return true + return false } diff --git a/common/tls/reality_server.go b/common/tls/reality_server.go index 10d60618..8ad59e14 100644 --- a/common/tls/reality_server.go +++ b/common/tls/reality_server.go @@ -254,5 +254,5 @@ func (c *realityConnWrapper) ReaderReplaceable() bool { } func (c *realityConnWrapper) WriterReplaceable() bool { - return true + return false } diff --git a/go.mod b/go.mod index 4cd26fa1..af91c67f 100644 --- a/go.mod +++ b/go.mod @@ -45,7 +45,7 @@ require ( github.com/sagernet/netlink v0.0.0-20260814022025-64455d367bbf github.com/sagernet/nftables v0.3.0-mod.4 github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 - github.com/sagernet/sing v0.9.6-0.20260920042621-643b9d01347d + github.com/sagernet/sing v0.9.6-0.20260920093512-38496bf4f1df github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.8 github.com/sagernet/sing-openconnect v0.0.0-20260810065514-53aa8058f8df diff --git a/go.sum b/go.sum index 1d604331..56b41eb1 100644 --- a/go.sum +++ b/go.sum @@ -318,8 +318,8 @@ github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4IC github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 h1:sW2O+DoNF+my1PGlSZ3zG2N+fdxLR3DC0l4sXCM0lBE= github.com/sagernet/quic-go v0.61.0-sing-box-mod.7/go.mod h1:hmLC8GJPp+BrpBgrgJnacvI4fZGIhasuy1ROJJ8GR4E= -github.com/sagernet/sing v0.9.6-0.20260920042621-643b9d01347d h1:4HL9hWcaBCAyIm4bPWxTtWa4Oz4YcLgSvLcjVIQdpdY= -github.com/sagernet/sing v0.9.6-0.20260920042621-643b9d01347d/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= +github.com/sagernet/sing v0.9.6-0.20260920093512-38496bf4f1df h1:KZSoa3WMDGrr7rCJOUfg0mgxvTkSWcsPk4dzRdt2+YQ= +github.com/sagernet/sing v0.9.6-0.20260920093512-38496bf4f1df/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6++yIa8XlDhxPkpR4p+7RUHVY2KTP9CPIGnWmOlO8= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.8 h1:mM0TLUkr36aEhgUHi9Qh60qcAbD2fZcmnJ30937xxrY= diff --git a/route/conn.go b/route/conn.go index c2ab90d5..fe718b50 100644 --- a/route/conn.go +++ b/route/conn.go @@ -274,13 +274,17 @@ func (m *ConnectionManager) connectionCopy(ctx context.Context, source net.Conn, _, err := bufio.CopyWithIncreateBuffer(destination, source, bufio.DefaultIncreaseBufferAfter, bufio.DefaultBatchSize) if err != nil { common.Close(source, destination) - } else if duplexDst, isDuplex := destination.(N.WriteCloser); isDuplex { - err = duplexDst.CloseWrite() - if err != nil { - common.Close(source, destination) - } } else { - destination.Close() + destinationWriter, _ := N.UnwrapCountWriter(destination, nil) + duplexDst, isDuplex := N.UnwrapWriter(destinationWriter).(N.WriteCloser) + if isDuplex { + err = duplexDst.CloseWrite() + if err != nil { + common.Close(source, destination) + } + } else { + destination.Close() + } } if done.Swap(true) { if onClose != nil { From f5e8cdae19cd5f6a1abf9cbd7990c6530de88c69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 20 Sep 2026 17:36:19 +0800 Subject: [PATCH 22/66] Fix gRPC transport half-close --- transport/v2raygrpc/conn.go | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/transport/v2raygrpc/conn.go b/transport/v2raygrpc/conn.go index 87be9661..5ad1ff5b 100644 --- a/transport/v2raygrpc/conn.go +++ b/transport/v2raygrpc/conn.go @@ -10,6 +10,8 @@ import ( "github.com/sagernet/sing/common/baderror" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" + + "google.golang.org/grpc" ) var _ net.Conn = (*GRPCConn)(nil) @@ -21,15 +23,15 @@ type GRPCConn struct { closeOnce sync.Once } -func NewGRPCConn(service GunService, cancel context.CancelCauseFunc) *GRPCConn { - //nolint:staticcheck - if client, isClient := service.(GunService_TunClient); isClient { - service = &clientConnWrapper{client} - } - return &GRPCConn{ +func NewGRPCConn(service GunService, cancel context.CancelCauseFunc) net.Conn { + conn := &GRPCConn{ GunService: service, cancel: cancel, } + if client, isClient := service.(grpc.ClientStream); isClient { + return &GRPCClientConn{GRPCConn: conn, client: client} + } + return conn } func (c *GRPCConn) Read(b []byte) (n int, err error) { @@ -95,12 +97,13 @@ func (c *GRPCConn) Upstream() any { return c.GunService } -var _ N.WriteCloser = (*clientConnWrapper)(nil) +var _ N.WriteCloser = (*GRPCClientConn)(nil) -type clientConnWrapper struct { - GunService_TunClient +type GRPCClientConn struct { + *GRPCConn + client grpc.ClientStream } -func (c *clientConnWrapper) CloseWrite() error { - return c.CloseSend() +func (c *GRPCClientConn) CloseWrite() error { + return c.client.CloseSend() } From aab74fea51cb14d02d619aa2d8617e35728b39a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 20 Sep 2026 18:25:21 +0800 Subject: [PATCH 23/66] Add libbox API to detect tun inbounds --- experimental/libbox/config.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/experimental/libbox/config.go b/experimental/libbox/config.go index 08aa8ff0..a3f47f5e 100644 --- a/experimental/libbox/config.go +++ b/experimental/libbox/config.go @@ -6,6 +6,7 @@ import ( "net/netip" "os" "reflect" + "slices" box "github.com/sagernet/sing-box" "github.com/sagernet/sing-box/adapter" @@ -269,3 +270,13 @@ func FormatConfig(configContent string) (*StringBox, error) { } return wrapString(buffer.String()), nil } + +func HasTunInbound(configContent string) (bool, error) { + options, err := parseConfig(baseContext(nil), configContent) + if err != nil { + return false, err + } + return slices.ContainsFunc(options.Inbounds, func(inbound option.Inbound) bool { + return inbound.Type == C.TypeTun + }), nil +} From 78afbb2e6239dac56309173dc029da13d9aa47f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 21 Sep 2026 12:26:53 +0800 Subject: [PATCH 24/66] Respect DNS query timeouts during deduplication --- dns/client.go | 106 +++++++++++++++++++++++++++++++------------------- 1 file changed, 67 insertions(+), 39 deletions(-) diff --git a/dns/client.go b/dns/client.go index d99aed0e..77fe1cbc 100644 --- a/dns/client.go +++ b/dns/client.go @@ -48,7 +48,7 @@ type Client struct { networkManager adapter.NetworkManager logger logger.ContextLogger cache *freelru.Cache[dnsCacheKey, *dns.Msg] - cacheLock compatible.Map[dnsCacheKey, chan struct{}] + cacheLock compatible.Map[dnsExchangeKey, chan struct{}] backgroundRefresh compatible.Map[dnsCacheKey, struct{}] } @@ -95,6 +95,11 @@ type dnsCacheKey struct { environment uint64 } +type dnsExchangeKey struct { + dnsCacheKey + timeout time.Duration +} + func (k dnsCacheKey) persistentName() string { name := k.transportTag if k.clientSubnet.IsValid() { @@ -251,6 +256,10 @@ func (o *exchangeOperation) release() { } func (c *Client) beginExchange(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool, allowWait bool) (*exchangeOperation, *dns.Msg, exchangeStatus, error) { + err := ctx.Err() + if err != nil { + return nil, nil, exchangeDone, err + } if len(message.Question) == 0 { if c.logger != nil { c.logger.WarnContext(ctx, "bad question size: ", len(message.Question)) @@ -286,8 +295,33 @@ func (c *Client) beginExchange(ctx context.Context, transport adapter.DNSTranspo if !disableCache { cacheKey := c.newCacheKey(transport, question, message, options) operation.cacheKey = cacheKey - cond, loaded := c.cacheLock.LoadOrStore(cacheKey, make(chan struct{})) - if loaded { + exchangeKey := dnsExchangeKey{dnsCacheKey: cacheKey, timeout: options.Timeout} + cond, loaded := c.cacheLock.LoadOrStore(exchangeKey, make(chan struct{})) + if !loaded { + operation.releaseCond = func() { + c.cacheLock.Delete(exchangeKey) + close(cond) + } + } + for { + response, ttl, isStale := c.loadResponse(cacheKey) + if response != nil { + if isStale && !options.DisableOptimisticCache { + c.backgroundRefreshDNS(transport, cacheKey, message.Copy(), options, responseChecker) + logOptimisticResponse(c.logger, ctx, response) + response.Id = message.Id + operation.release() + return nil, response, exchangeDone, nil + } else if !isStale { + logCachedResponse(c.logger, ctx, response, ttl) + response.Id = message.Id + operation.release() + return nil, response, exchangeDone, nil + } + } + if !loaded { + break + } if !allowWait { return nil, nil, exchangeWait, nil } @@ -296,28 +330,13 @@ func (c *Client) beginExchange(ctx context.Context, transport adapter.DNSTranspo case <-ctx.Done(): return nil, nil, exchangeDone, ctx.Err() } + err = ctx.Err() + if err != nil { + return nil, nil, exchangeDone, err + } cacheKey = c.newCacheKey(transport, question, message, options) operation.cacheKey = cacheKey - } else { - operation.releaseCond = func() { - c.cacheLock.Delete(cacheKey) - close(cond) - } - } - response, ttl, isStale := c.loadResponse(cacheKey) - if response != nil { - if isStale && !options.DisableOptimisticCache { - c.backgroundRefreshDNS(transport, cacheKey, message.Copy(), options, responseChecker) - logOptimisticResponse(c.logger, ctx, response) - response.Id = message.Id - operation.release() - return nil, response, exchangeDone, nil - } else if !isStale { - logCachedResponse(c.logger, ctx, response, ttl) - response.Id = message.Id - operation.release() - return nil, response, exchangeDone, nil - } + loaded = false } } @@ -379,12 +398,17 @@ func (c *Client) finishExchange(transport adapter.DNSTransport, operation *excha } func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool) (*dns.Msg, error) { + if options.Timeout == 0 { + options.Timeout = c.timeout + } + ctx, cancel := context.WithTimeout(ctx, options.Timeout) + defer cancel() operation, earlyResponse, status, err := c.beginExchange(ctx, transport, message, options, responseChecker, true) if status != exchangeReady { return earlyResponse, err } defer operation.release() - response, err := c.exchangeToTransport(operation.ctx, transport, operation.message, options.Timeout) + response, err := c.exchangeToTransport(operation.ctx, transport, operation.message) if err != nil { return nil, err } @@ -392,18 +416,26 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m } func (c *Client) ExchangeAsync(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool, callback func(response *dns.Msg, err error)) { + if options.Timeout == 0 { + options.Timeout = c.timeout + } + ctx, cancel := context.WithTimeout(ctx, options.Timeout) operation, earlyResponse, status, err := c.beginExchange(ctx, transport, message, options, responseChecker, false) switch status { case exchangeDone: + cancel() callback(earlyResponse, err) return case exchangeWait: go func() { - callback(c.Exchange(ctx, transport, message, options, responseChecker)) + response, exchangeErr := c.Exchange(ctx, transport, message, options, responseChecker) + cancel() + callback(response, exchangeErr) }() return } finish := func(response *dns.Msg, exchangeErr error) { + cancel() if exchangeErr != nil { operation.release() callback(nil, exchangeErr) @@ -413,7 +445,7 @@ func (c *Client) ExchangeAsync(ctx context.Context, transport adapter.DNSTranspo operation.release() callback(finishedResponse, finishErr) } - c.exchangeToTransportAsync(operation.ctx, transport, operation.message, options.Timeout, finish) + c.exchangeToTransportAsync(operation.ctx, transport, operation.message, finish) } func (c *Client) Lookup(ctx context.Context, transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool) ([]netip.Addr, error) { @@ -644,8 +676,14 @@ func (c *Client) backgroundRefreshDNS(transport adapter.DNSTransport, key dnsCac } go func() { defer c.backgroundRefresh.Delete(key) + timeout := options.Timeout + if timeout == 0 { + timeout = c.timeout + } ctx := adapter.ContextWithDNSTransportTag(c.ctx, transport.Tag()) - response, err := c.exchangeToTransport(ctx, transport, message, options.Timeout) + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + response, err := c.exchangeToTransport(ctx, transport, message) if err != nil { if c.logger != nil { c.logger.DebugContext(ctx, "optimistic refresh failed for ", FqdnToDomain(key.Name), ": ", err) @@ -707,12 +745,7 @@ func stripDNSPadding(response *dns.Msg) { } } -func (c *Client) exchangeToTransport(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, timeout time.Duration) (*dns.Msg, error) { - if timeout == 0 { - timeout = c.timeout - } - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() +func (c *Client) exchangeToTransport(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg) (*dns.Msg, error) { response, err := transport.Exchange(ctx, message) if err == nil { stripDNSPadding(response) @@ -725,13 +758,8 @@ func (c *Client) exchangeToTransport(ctx context.Context, transport adapter.DNST return nil, err } -func (c *Client) exchangeToTransportAsync(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, timeout time.Duration, callback func(response *dns.Msg, err error)) { - if timeout == 0 { - timeout = c.timeout - } - ctx, cancel := context.WithTimeout(ctx, timeout) +func (c *Client) exchangeToTransportAsync(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, callback func(response *dns.Msg, err error)) { transport.ExchangeAsync(ctx, message, func(response *dns.Msg, err error) { - cancel() if err == nil { stripDNSPadding(response) callback(response, nil) From 1ea3bb4563e6ee6f72bfc8b9d82fe6898ee87478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 21 Sep 2026 12:30:41 +0800 Subject: [PATCH 25/66] Fix OOM report generated on clean shutdown --- experimental/libbox/oom_report.go | 4 ---- service/oomkiller/recorder.go | 15 +-------------- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/experimental/libbox/oom_report.go b/experimental/libbox/oom_report.go index 6cccdb46..d7bfcf58 100644 --- a/experimental/libbox/oom_report.go +++ b/experimental/libbox/oom_report.go @@ -31,7 +31,6 @@ var oomReportProfiles = []string{ type oomReportMetadata struct { reportMetadata RecordedAt string `json:"recordedAt"` - EndedAt string `json:"endedAt,omitempty"` MemoryLimit string `json:"memoryLimit,omitempty"` MemoryUsage string `json:"memoryUsage"` AvailableMemory string `json:"availableMemory,omitempty"` @@ -51,9 +50,6 @@ func OOMRecorderOptions(startedService *daemon.StartedService) oomkiller.Recorde Snapshots: status.Snapshots, } metadata.StartedAt = status.StartedAt.UTC().Format(time.RFC3339) - if !status.EndedAt.IsZero() { - metadata.EndedAt = status.EndedAt.UTC().Format(time.RFC3339) - } if status.MemoryLimit > 0 { metadata.MemoryLimit = byteformats.FormatMemoryBytes(status.MemoryLimit) } diff --git a/service/oomkiller/recorder.go b/service/oomkiller/recorder.go index c8805ef8..1ec6b4fa 100644 --- a/service/oomkiller/recorder.go +++ b/service/oomkiller/recorder.go @@ -64,7 +64,6 @@ type RecorderOptions struct { type ReportStatus struct { StartedAt time.Time RecordedAt time.Time - EndedAt time.Time MemoryLimit uint64 PeakMemory uint64 MinAvailable uint64 @@ -87,7 +86,6 @@ type Recorder struct { closed bool draftCreated bool draftLock *os.File - notable bool status ReportStatus lastRowAt time.Time lastRowState pressureState @@ -200,16 +198,8 @@ func (r *Recorder) Close() error { if !r.draftCreated { return nil } - if !r.notable { - r.releaseDraftLocked() - return os.RemoveAll(r.draftPath) - } - r.status.EndedAt = time.Now() - r.writeLogLocked() - r.writeMetadataLocked() r.releaseDraftLocked() - promoteDirectory(r.draftPath, filepath.Join(r.basePath, ReportsDirectoryName)) - return nil + return os.RemoveAll(r.draftPath) } func (r *Recorder) releaseDraftLocked() { @@ -328,7 +318,6 @@ func (r *Recorder) sample(sample memorySample, state pressureState, connections func (r *Recorder) recordPressure(sample memorySample) { r.access.Lock() defer r.access.Unlock() - r.notable = true r.observeLocked(sample) r.denseUntil = time.Now().Add(denseSampleWindow) r.appendEventLocked(eventRecord{Type: eventTypePressure, MemoryBytes: sample.usage, AvailableBytes: sample.available}) @@ -345,7 +334,6 @@ func (r *Recorder) recordStateChange(state pressureState, sample memorySample) { func (r *Recorder) recordReset(reason string, before memorySample, after memorySample, connections int, reportOnly bool) { r.access.Lock() defer r.access.Unlock() - r.notable = true r.observeLocked(before) r.denseUntil = time.Now().Add(denseSampleWindow) r.appendEventLocked(eventRecord{ @@ -421,7 +409,6 @@ func (r *Recorder) snapshot(reason string, sample memorySample, force bool) erro } r.access.Lock() defer r.access.Unlock() - r.notable = true r.status.Snapshots = sequence r.observeLocked(sample) r.writeLogLocked() From 886422d2e001787b0d11a49ea8876883f26929e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 21 Sep 2026 22:00:03 +0800 Subject: [PATCH 26/66] Fix FakeIP reset failing on missing buckets --- experimental/cachefile/fakeip.go | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/experimental/cachefile/fakeip.go b/experimental/cachefile/fakeip.go index 0eff2ebe..814c2048 100644 --- a/experimental/cachefile/fakeip.go +++ b/experimental/cachefile/fakeip.go @@ -189,14 +189,15 @@ func (c *CacheFile) FakeIPLoadDomain(domain string, isIPv6 bool) (netip.Addr, bo func (c *CacheFile) FakeIPReset() error { return c.batch(func(tx *bbolt.Tx) error { - err := tx.DeleteBucket(bucketFakeIP) - if err != nil { - return err + for _, bucketName := range [][]byte{bucketFakeIP, bucketFakeIPDomain4, bucketFakeIPDomain6} { + if tx.Bucket(bucketName) == nil { + continue + } + err := tx.DeleteBucket(bucketName) + if err != nil { + return err + } } - err = tx.DeleteBucket(bucketFakeIPDomain4) - if err != nil { - return err - } - return tx.DeleteBucket(bucketFakeIPDomain6) + return nil }) } From 19f57607a9dcd71124a103a75f60d9b3eb8ec75d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 21 Sep 2026 22:56:08 +0800 Subject: [PATCH 27/66] Fix HTTP sniffer storing IP literal hosts as domain --- common/sniff/http.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/sniff/http.go b/common/sniff/http.go index 012f2c99..cb69574e 100644 --- a/common/sniff/http.go +++ b/common/sniff/http.go @@ -23,6 +23,6 @@ func HTTPHost(_ context.Context, metadata *adapter.InboundContext, reader io.Rea } } metadata.Protocol = C.ProtocolHTTP - metadata.Domain = M.ParseSocksaddr(request.Host).AddrString() + metadata.Domain = M.ParseSocksaddr(request.Host).Fqdn return nil } From ca7216a3811f0f99d52ffda911a114178721dbae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 21 Sep 2026 23:38:34 +0800 Subject: [PATCH 28/66] Fix UDP domain destination lost in outbound groups --- common/interrupt/conn.go | 27 +++++++-------------------- common/interrupt/group.go | 2 +- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/common/interrupt/conn.go b/common/interrupt/conn.go index 94caa915..fa0b7655 100644 --- a/common/interrupt/conn.go +++ b/common/interrupt/conn.go @@ -4,29 +4,16 @@ import ( "net" "github.com/sagernet/sing/common/bufio" + N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/common/x/list" ) -/*type GroupedConn interface { - MarkAsInternal() -} - -func MarkAsInternal(conn any) { - if groupedConn, isGroupConn := common.Cast[GroupedConn](conn); isGroupConn { - groupedConn.MarkAsInternal() - } -}*/ - type Conn struct { net.Conn group *Group element *list.Element[*groupConnItem] } -/*func (c *Conn) MarkAsInternal() { - c.element.Value.internal = true -}*/ - func (c *Conn) Close() error { c.group.access.Lock() defer c.group.access.Unlock() @@ -47,20 +34,20 @@ func (c *Conn) Upstream() any { } type PacketConn struct { - net.PacketConn + N.NetPacketConn group *Group element *list.Element[*groupConnItem] } -/*func (c *PacketConn) MarkAsInternal() { - c.element.Value.internal = true -}*/ +func newPacketConn(group *Group, conn net.PacketConn, element *list.Element[*groupConnItem]) *PacketConn { + return &PacketConn{NetPacketConn: bufio.NewPacketConn(conn), group: group, element: element} +} func (c *PacketConn) Close() error { c.group.access.Lock() defer c.group.access.Unlock() c.group.connections.Remove(c.element) - return c.PacketConn.Close() + return c.NetPacketConn.Close() } func (c *PacketConn) ReaderReplaceable() bool { @@ -72,5 +59,5 @@ func (c *PacketConn) WriterReplaceable() bool { } func (c *PacketConn) Upstream() any { - return bufio.NewPacketConn(c.PacketConn) + return c.NetPacketConn } diff --git a/common/interrupt/group.go b/common/interrupt/group.go index ba2e7f73..63ae199e 100644 --- a/common/interrupt/group.go +++ b/common/interrupt/group.go @@ -33,7 +33,7 @@ func (g *Group) NewPacketConn(conn net.PacketConn, isExternal bool) net.PacketCo g.access.Lock() defer g.access.Unlock() item := g.connections.PushBack(&groupConnItem{conn, isExternal}) - return &PacketConn{PacketConn: conn, group: g, element: item} + return newPacketConn(g, conn, item) } func (g *Group) Interrupt(interruptExternalConnections bool) { From 0d91c4547921a7b72e9c9913e2bc6f3a556801e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 22 Sep 2026 09:51:17 +0800 Subject: [PATCH 29/66] Fix file manager treating Windows absolute paths as relative --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index af91c67f..be7473ab 100644 --- a/go.mod +++ b/go.mod @@ -45,7 +45,7 @@ require ( github.com/sagernet/netlink v0.0.0-20260814022025-64455d367bbf github.com/sagernet/nftables v0.3.0-mod.4 github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 - github.com/sagernet/sing v0.9.6-0.20260920093512-38496bf4f1df + github.com/sagernet/sing v0.9.6-0.20260922013354-87c33f17688f github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.8 github.com/sagernet/sing-openconnect v0.0.0-20260810065514-53aa8058f8df diff --git a/go.sum b/go.sum index 56b41eb1..d3a9bbbd 100644 --- a/go.sum +++ b/go.sum @@ -318,8 +318,8 @@ github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4IC github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 h1:sW2O+DoNF+my1PGlSZ3zG2N+fdxLR3DC0l4sXCM0lBE= github.com/sagernet/quic-go v0.61.0-sing-box-mod.7/go.mod h1:hmLC8GJPp+BrpBgrgJnacvI4fZGIhasuy1ROJJ8GR4E= -github.com/sagernet/sing v0.9.6-0.20260920093512-38496bf4f1df h1:KZSoa3WMDGrr7rCJOUfg0mgxvTkSWcsPk4dzRdt2+YQ= -github.com/sagernet/sing v0.9.6-0.20260920093512-38496bf4f1df/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= +github.com/sagernet/sing v0.9.6-0.20260922013354-87c33f17688f h1:t2lyXQFJmCPsybxKqYE8YlfDIvxK9Z343HOvAX7pIKA= +github.com/sagernet/sing v0.9.6-0.20260922013354-87c33f17688f/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6++yIa8XlDhxPkpR4p+7RUHVY2KTP9CPIGnWmOlO8= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.8 h1:mM0TLUkr36aEhgUHi9Qh60qcAbD2fZcmnJ30937xxrY= From 4abede8ceb13d69b87e560c86bf76057e1a0eafa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 23 Sep 2026 14:08:52 +0800 Subject: [PATCH 30/66] Fix network reset --- box.go | 4 ++-- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/box.go b/box.go index 7bbedd59..92cd1ecd 100644 --- a/box.go +++ b/box.go @@ -227,13 +227,13 @@ func New(options Options) (*Box, error) { } service.MustRegister[adapter.DNSRouter](ctx, dnsRouter) service.MustRegister[adapter.DNSRuleSetUpdateValidator](ctx, dnsRouter) + connectionManager := route.NewConnectionManager(logFactory.NewLogger("connection")) + service.MustRegister[adapter.ConnectionManager](ctx, connectionManager) networkManager, err := route.NewNetworkManager(ctx, logFactory.NewLogger("network"), routeOptions, dnsOptions) if err != nil { return nil, E.Cause(err, "initialize network manager") } service.MustRegister[adapter.NetworkManager](ctx, networkManager) - connectionManager := route.NewConnectionManager(logFactory.NewLogger("connection")) - service.MustRegister[adapter.ConnectionManager](ctx, connectionManager) // Must register after ConnectionManager: the Apple HTTP engine's proxy bridge reads it from the context when Manager.Start resolves the default client. httpClientManager := httpclient.NewManager(ctx, logFactory.NewLogger("httpclient"), options.HTTPClients, routeOptions.DefaultHTTPClient) service.MustRegister[adapter.HTTPClientManager](ctx, httpClientManager) diff --git a/go.mod b/go.mod index be7473ab..ee4c4df4 100644 --- a/go.mod +++ b/go.mod @@ -55,7 +55,7 @@ require ( github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1 github.com/sagernet/sing-snell v0.0.0-20260829071736-20f2eaec77c3 - github.com/sagernet/sing-tun v0.9.5 + github.com/sagernet/sing-tun v0.9.6-0.20260924001923-ddaa4ca25e3b github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca github.com/sagernet/sing-vmess v0.2.8 github.com/sagernet/smux v1.5.50-sing-box-mod.1 diff --git a/go.sum b/go.sum index d3a9bbbd..28865946 100644 --- a/go.sum +++ b/go.sum @@ -338,8 +338,8 @@ github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkG github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= github.com/sagernet/sing-snell v0.0.0-20260829071736-20f2eaec77c3 h1:wjEw0lmCSC+oqh7GD4rbMB/dk7+hZ2VopUj4xM5/Jrc= github.com/sagernet/sing-snell v0.0.0-20260829071736-20f2eaec77c3/go.mod h1:et8Lws4f5QbOrY65DmjevHGup3mijJkhswkto6cwciM= -github.com/sagernet/sing-tun v0.9.5 h1:Kzprh1wjR2WzS+nh0gAPa4xKSqEc6AYG+Wcz1gR4fCg= -github.com/sagernet/sing-tun v0.9.5/go.mod h1:3EgPst7agntRO7D6GOsiZ1l9FoqdLeuWmKT5TnWkmf0= +github.com/sagernet/sing-tun v0.9.6-0.20260924001923-ddaa4ca25e3b h1:tjQUA4fQi0OWDMMYfRigzvNlBTYyP/01e+P9BT/RZ4g= +github.com/sagernet/sing-tun v0.9.6-0.20260924001923-ddaa4ca25e3b/go.mod h1:3EgPst7agntRO7D6GOsiZ1l9FoqdLeuWmKT5TnWkmf0= github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca h1:5wA+IE0Fq1CGVLOgSpm0gKKZ03HzugcJ2JyhmDqeX6A= github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca/go.mod h1:ADAZZU85MnM91XNhd2TdZRTaIbA7RjTZuQ1KCHcUbNg= github.com/sagernet/sing-vmess v0.2.8 h1:xd5nnDOMlC76RgrLksS4jlk3eMt3c3CvQY3NsjWPWeI= From 6b6c20a24811968ca646e9a274e483ce155db296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 23 Sep 2026 14:08:52 +0800 Subject: [PATCH 31/66] Fix resolved service --- service/resolved/resolve1.go | 90 +++++++++++------------------------ service/resolved/service.go | 11 ++--- service/resolved/transport.go | 33 ++++++++++--- 3 files changed, 58 insertions(+), 76 deletions(-) diff --git a/service/resolved/resolve1.go b/service/resolved/resolve1.go index dadc975b..a02d0343 100644 --- a/service/resolved/resolve1.go +++ b/service/resolved/resolve1.go @@ -5,12 +5,10 @@ package resolved import ( "context" "errors" - "fmt" "net/netip" "os" "os/user" "path/filepath" - "slices" "strconv" "strings" "syscall" @@ -79,17 +77,27 @@ type LinkDomain struct { func (t *resolve1Manager) getLink(ifIndex int32) (*TransportLink, *dbus.Error) { link, loaded := t.links[ifIndex] if !loaded { - link = &TransportLink{} - t.links[ifIndex] = link iif, err := t.network.InterfaceFinder().ByIndex(int(ifIndex)) if err != nil { return nil, wrapError(err) } - link.iif = iif + link = &TransportLink{iif: iif} + t.links[ifIndex] = link } return link, nil } +func (t *resolve1Manager) interfaceName(ifIndex int32) string { + if ifIndex == 0 { + return "*" + } + iif, err := t.network.InterfaceFinder().ByIndex(int(ifIndex)) + if err != nil { + return F.ToString(ifIndex) + } + return iif.Name +} + func (t *resolve1Manager) getSenderProcess(sender dbus.Sender) (int32, error) { var senderPid int32 dbusObject := t.systemBus.Object("org.freedesktop.DBus", "/org/freedesktop/DBus") @@ -203,12 +211,6 @@ func familyToString(family int32) string { } func (t *resolve1Manager) ResolveHostname(sender dbus.Sender, ifIndex int32, hostname string, family int32, flags uint64) (addresses []Address, canonical string, outflags uint64, err *dbus.Error) { - t.linkAccess.Lock() - link, err := t.getLink(ifIndex) - if err != nil { - return - } - t.linkAccess.Unlock() var strategy C.DomainStrategy switch family { case syscall.AF_UNSPEC: @@ -218,12 +220,12 @@ func (t *resolve1Manager) ResolveHostname(sender dbus.Sender, ifIndex int32, hos case syscall.AF_INET6: strategy = C.DomainStrategyIPv6Only } - ctx := t.logRequest(sender, "ResolveHostname ", link.iif.Name, " ", hostname, " ", familyToString(family), " ", flags) + ctx := t.logRequest(sender, "ResolveHostname", t.interfaceName(ifIndex), hostname, familyToString(family), flags) responseAddresses, lookupErr := t.dnsRouter.Lookup(ctx, hostname, adapter.DNSQueryOptions{ LookupStrategy: strategy, }) if lookupErr != nil { - err = wrapError(err) + err = wrapError(lookupErr) return } addresses = common.Map(responseAddresses, func(it netip.Addr) Address { @@ -244,48 +246,27 @@ func (t *resolve1Manager) ResolveHostname(sender dbus.Sender, ifIndex int32, hos } func (t *resolve1Manager) ResolveAddress(sender dbus.Sender, ifIndex int32, family int32, address []byte, flags uint64) (names []Name, outflags uint64, err *dbus.Error) { - t.linkAccess.Lock() - link, err := t.getLink(ifIndex) - if err != nil { - return - } - t.linkAccess.Unlock() addr, ok := netip.AddrFromSlice(address) if !ok { err = wrapError(E.New("invalid address")) return } - var nibbles []string - for _, v := range slices.Backward(address) { - b := v - nibbles = append(nibbles, fmt.Sprintf("%x", b&0x0F)) - nibbles = append(nibbles, fmt.Sprintf("%x", b>>4)) - } - var ptrDomain string - if addr.Is4() { - ptrDomain = strings.Join(nibbles, ".") + ".in-addr.arpa." - } else { - ptrDomain = strings.Join(nibbles, ".") + ".ip6.arpa." - } request := &mDNS.Msg{ MsgHdr: mDNS.MsgHdr{ RecursionDesired: true, }, Question: []mDNS.Question{ { - Name: mDNS.Fqdn(ptrDomain), + Name: common.Must1(mDNS.ReverseAddr(addr.String())), Qtype: mDNS.TypePTR, Qclass: mDNS.ClassINET, }, }, } - ctx := t.logRequest(sender, "ResolveAddress ", link.iif.Name, familyToString(family), addr, flags) - var metadata adapter.InboundContext - metadata.InboundType = t.Type() - metadata.Inbound = t.Tag() - response, lookupErr := t.dnsRouter.Exchange(adapter.WithContext(ctx, &metadata), request, adapter.DNSQueryOptions{}) + ctx := t.logRequest(sender, "ResolveAddress", t.interfaceName(ifIndex), familyToString(family), addr, flags) + response, lookupErr := t.dnsRouter.Exchange(ctx, request, adapter.DNSQueryOptions{}) if lookupErr != nil { - err = wrapError(err) + err = wrapError(lookupErr) return } if response.Rcode != mDNS.RcodeSuccess { @@ -305,12 +286,6 @@ func (t *resolve1Manager) ResolveAddress(sender dbus.Sender, ifIndex int32, fami } func (t *resolve1Manager) ResolveRecord(sender dbus.Sender, ifIndex int32, hostname string, qClass uint16, qType uint16, flags uint64) (records []ResourceRecord, outflags uint64, err *dbus.Error) { - t.linkAccess.Lock() - link, err := t.getLink(ifIndex) - if err != nil { - return - } - t.linkAccess.Unlock() request := &mDNS.Msg{ MsgHdr: mDNS.MsgHdr{ RecursionDesired: true, @@ -323,11 +298,8 @@ func (t *resolve1Manager) ResolveRecord(sender dbus.Sender, ifIndex int32, hostn }, }, } - ctx := t.logRequest(sender, "ResolveRecord", link.iif.Name, hostname, mDNS.Class(qClass), mDNS.Type(qType), flags) - var metadata adapter.InboundContext - metadata.InboundType = t.Type() - metadata.Inbound = t.Tag() - response, exchangeErr := t.dnsRouter.Exchange(adapter.WithContext(ctx, &metadata), request, adapter.DNSQueryOptions{}) + ctx := t.logRequest(sender, "ResolveRecord", t.interfaceName(ifIndex), hostname, mDNS.Class(qClass), mDNS.Type(qType), flags) + response, exchangeErr := t.dnsRouter.Exchange(ctx, request, adapter.DNSQueryOptions{}) if exchangeErr != nil { err = wrapError(exchangeErr) return @@ -353,13 +325,6 @@ func (t *resolve1Manager) ResolveRecord(sender dbus.Sender, ifIndex int32, hostn } func (t *resolve1Manager) ResolveService(sender dbus.Sender, ifIndex int32, hostname string, sType string, domain string, family int32, flags uint64) (srvData []SRVRecord, txtData []TXTRecord, canonicalName string, canonicalType string, canonicalDomain string, outflags uint64, err *dbus.Error) { - t.linkAccess.Lock() - link, err := t.getLink(ifIndex) - if err != nil { - return - } - t.linkAccess.Unlock() - serviceName := hostname if hostname != "" && !strings.HasSuffix(hostname, ".") { serviceName += "." @@ -373,7 +338,7 @@ func (t *resolve1Manager) ResolveService(sender dbus.Sender, ifIndex int32, host serviceName += "." } - ctx := t.logRequest(sender, "ResolveService ", link.iif.Name, " ", hostname, " ", sType, " ", domain, " ", familyToString(family), " ", flags) + ctx := t.logRequest(sender, "ResolveService", t.interfaceName(ifIndex), hostname, sType, domain, familyToString(family), flags) srvRequest := &mDNS.Msg{ MsgHdr: mDNS.MsgHdr{ @@ -387,10 +352,7 @@ func (t *resolve1Manager) ResolveService(sender dbus.Sender, ifIndex int32, host }, }, } - var metadata adapter.InboundContext - metadata.InboundType = t.Type() - metadata.Inbound = t.Tag() - srvResponse, exchangeErr := t.dnsRouter.Exchange(adapter.WithContext(ctx, &metadata), srvRequest, adapter.DNSQueryOptions{}) + srvResponse, exchangeErr := t.dnsRouter.Exchange(ctx, srvRequest, adapter.DNSQueryOptions{}) if exchangeErr != nil { err = wrapError(exchangeErr) return @@ -606,8 +568,12 @@ func (t *resolve1Manager) RevertLink(sender dbus.Sender, ifIndex int32) *dbus.Er return wrapError(err) } delete(t.links, ifIndex) + t.defaultRouteSequence = common.Filter(t.defaultRouteSequence, func(it int32) bool { return it != ifIndex }) t.log(sender, "RevertLink ", link.iif.Name) - return t.postUpdate(link) + if t.deleteCallback != nil { + t.deleteCallback(link) + } + return nil } // TODO: implement RegisterService, UnregisterService diff --git a/service/resolved/service.go b/service/resolved/service.go index a634b9b2..f0d1aa0f 100644 --- a/service/resolved/service.go +++ b/service/resolved/service.go @@ -185,12 +185,12 @@ func (i *Service) exchangePacket0(ctx context.Context, buffer *buf.Buffer, oob [ func (i *Service) onNetworkUpdate() { i.linkAccess.Lock() defer i.linkAccess.Unlock() - var deleteIfIndex []int for ifIndex, link := range i.links { - iif, err := i.network.InterfaceFinder().ByIndex(int(ifIndex)) - if err != nil || iif != link.iif { - deleteIfIndex = append(deleteIfIndex, int(ifIndex)) + netInterface, err := net.InterfaceByIndex(int(ifIndex)) + if err == nil && netInterface.Name == link.iif.Name { + continue } + delete(i.links, ifIndex) i.defaultRouteSequence = common.Filter(i.defaultRouteSequence, func(it int32) bool { return it != ifIndex }) @@ -198,9 +198,6 @@ func (i *Service) onNetworkUpdate() { i.deleteCallback(link) } } - for _, ifIndex := range deleteIfIndex { - delete(i.links, int32(ifIndex)) - } } func (conf *TransportLink) nameList(ndots int, name string) []string { diff --git a/service/resolved/transport.go b/service/resolved/transport.go index 858cb6b0..2f476a76 100644 --- a/service/resolved/transport.go +++ b/service/resolved/transport.go @@ -192,7 +192,7 @@ func (t *Transport) updateTransports(link *TransportLink) error { Enabled: true, ServerName: serverAddr.String(), })) - transports = append(transports, transport.NewTLSRaw(t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, 53), tlsConfig)) + transports = append(transports, transport.NewTLSRaw(t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, 853), tlsConfig)) } else { transports = append(transports, transport.NewUDPRaw(t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, 53))) @@ -203,7 +203,11 @@ func (t *Transport) updateTransports(link *TransportLink) error { if !ok { return os.ErrInvalid } + serverPort := address.Port if link.dnsOverTLS { + if serverPort == 0 { + serverPort = 853 + } var serverName string if address.Name != "" { serverName = address.Name @@ -214,10 +218,13 @@ func (t *Transport) updateTransports(link *TransportLink) error { Enabled: true, ServerName: serverName, })) - transports = append(transports, transport.NewTLSRaw(t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, address.Port), tlsConfig)) + transports = append(transports, transport.NewTLSRaw(t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, serverPort), tlsConfig)) } else { - transports = append(transports, transport.NewUDPRaw(t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, address.Port))) + if serverPort == 0 { + serverPort = 53 + } + transports = append(transports, transport.NewUDPRaw(t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, serverPort))) } } t.linkServers[link] = &LinkServers{ @@ -248,7 +255,7 @@ func (t *Transport) PreferredDomain(domain string) bool { if linkDomain.Domain == "." { continue } - if mDNS.IsSubDomain(linkDomain.Domain, domain) { + if mDNS.IsSubDomain(mDNS.Fqdn(linkDomain.Domain), domain) { return true } } @@ -273,15 +280,25 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, func (t *Transport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { question := message.Question[0] - var selectedLink *TransportLink + var ( + selectedLink *TransportLink + selectedLabels int + names []string + ) t.service.linkAccess.RLock() for _, link := range t.service.links { for _, domain := range link.domain { if domain.Domain == "." && domain.RoutingOnly && !t.acceptDefaultResolvers { continue } - if mDNS.IsSubDomain(domain.Domain, question.Name) { + domainName := mDNS.Fqdn(domain.Domain) + if !mDNS.IsSubDomain(domainName, question.Name) { + continue + } + labels := mDNS.CountLabel(domainName) + if selectedLink == nil || labels > selectedLabels { selectedLink = link + selectedLabels = labels } } } @@ -293,6 +310,9 @@ func (t *Transport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callba } } } + if selectedLink != nil { + names = selectedLink.nameList(t.ndots, question.Name) + } t.service.linkAccess.RUnlock() if selectedLink == nil { callback(dns.FixedResponseStatus(message, mDNS.RcodeNameError), nil) @@ -305,7 +325,6 @@ func (t *Transport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callba callback(dns.FixedResponseStatus(message, mDNS.RcodeNameError), nil) return } - names := servers.Link.nameList(t.ndots, question.Name) if len(names) == 0 { callback(nil, E.New("invalid domain: ", question.Name)) return From 111ecb3483e59627286b41f522cfcce270323530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 23 Sep 2026 23:58:58 +0800 Subject: [PATCH 32/66] Fix network reset on every startup --- route/network.go | 48 ++++++++++++++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/route/network.go b/route/network.go index 9d74fed3..68d1cd2e 100644 --- a/route/network.go +++ b/route/network.go @@ -65,6 +65,7 @@ type NetworkManager struct { interfaceUpdateElement *list.Element[tun.DefaultInterfaceUpdateCallback] interfaceUpdateAccess sync.Mutex interfaceUpdateCancel context.CancelFunc + networkResetPending bool resetRunAccess sync.Mutex powerUpdateAccess sync.Mutex powerUpdateCancel context.CancelFunc @@ -165,6 +166,7 @@ func (r *NetworkManager) Start(stage adapter.StartStage) error { if err != nil { return err } + r.interfaceUpdateElement = r.interfaceMonitor.RegisterCallback(r.notifyInterfaceUpdate) } case adapter.StartStateStart: if C.IsAndroid && r.platformInterface == nil { @@ -201,7 +203,12 @@ func (r *NetworkManager) Start(stage adapter.StartStage) error { } } } + r.interfaceUpdateAccess.Lock() r.startedCtx, r.startedCancel = context.WithCancel(r.ctx) + if r.interfaceMonitor != nil { + r.dispatchInterfaceUpdateLocked() + } + r.interfaceUpdateAccess.Unlock() if runtime.GOOS == "windows" { powerListener, err := winpowrprof.NewEventListener(r.notifyWindowsPowerEvent) if err == nil { @@ -218,13 +225,6 @@ func (r *NetworkManager) Start(stage adapter.StartStage) error { return E.Cause(err, "start power listener") } } - if r.interfaceMonitor != nil { - r.interfaceUpdateElement = r.interfaceMonitor.RegisterCallback(r.notifyInterfaceUpdate) - // Every monitor implementation has already delivered the initial state when Start returned: - // sing-tun checks routes synchronously, the Apple client blocks on the first NWPathMonitor update, - // and the Android client resolves the active network before setListener returns. - r.notifyInterfaceUpdate(r.interfaceMonitor.DefaultInterface(), 0) - } } return nil } @@ -519,21 +519,28 @@ func (r *NetworkManager) ResetNetwork(ctx context.Context) { r.router.ResetNetwork() } -func (r *NetworkManager) notifyInterfaceUpdate(defaultInterface *control.Interface, flags int) { +func (r *NetworkManager) notifyInterfaceUpdate(_ *control.Interface, _ int) { + r.interfaceUpdateAccess.Lock() + defer r.interfaceUpdateAccess.Unlock() + r.networkResetPending = true + if r.startedCtx != nil { + r.dispatchInterfaceUpdateLocked() + } +} + +func (r *NetworkManager) dispatchInterfaceUpdateLocked() { + defaultInterface := r.interfaceMonitor.DefaultInterface() if defaultInterface == nil { r.pauseManager.NetworkPause() r.logger.Error("missing default interface") return } r.pauseManager.NetworkWake() - updateContext, updateCancel := context.WithCancel(r.startedCtx) - r.interfaceUpdateAccess.Lock() - previousCancel := r.interfaceUpdateCancel - r.interfaceUpdateCancel = updateCancel - r.interfaceUpdateAccess.Unlock() - if previousCancel != nil { - previousCancel() + if r.interfaceUpdateCancel != nil { + r.interfaceUpdateCancel() } + updateContext, updateCancel := context.WithCancel(r.startedCtx) + r.interfaceUpdateCancel = updateCancel go r.updateInterface(updateContext, defaultInterface) } @@ -575,10 +582,15 @@ func (r *NetworkManager) updateInterface(ctx context.Context, defaultInterface * return } r.updateNetworkEnvironment() - if ctx.Err() != nil { - return + r.interfaceUpdateAccess.Lock() + resetNetwork := ctx.Err() == nil && r.networkResetPending + if resetNetwork { + r.networkResetPending = false + } + r.interfaceUpdateAccess.Unlock() + if resetNetwork { + r.ResetNetwork(ctx) } - r.ResetNetwork(ctx) } func (r *NetworkManager) notifyWindowsPowerEvent(event int) { From 17c52d03b34207e83a5dd251515ec675371defe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 23 Sep 2026 22:09:48 +0800 Subject: [PATCH 33/66] Fix hysteria2 realm STUN resolution ignoring default domain resolver --- adapter/dns.go | 22 ------ common/dialer/dialer.go | 126 ++++++++++++++++----------------- option/hysteria2.go | 8 +++ protocol/hysteria2/inbound.go | 13 ++-- protocol/hysteria2/outbound.go | 18 +++-- 5 files changed, 90 insertions(+), 97 deletions(-) diff --git a/adapter/dns.go b/adapter/dns.go index cf949e3e..15df285d 100644 --- a/adapter/dns.go +++ b/adapter/dns.go @@ -8,9 +8,7 @@ import ( 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" - "github.com/sagernet/sing/service" "github.com/miekg/dns" ) @@ -45,26 +43,6 @@ type DNSQueryOptions struct { RemoveClientSubnet bool } -func DNSQueryOptionsFrom(ctx context.Context, options *option.DomainResolveOptions) (DNSQueryOptions, error) { - if options == nil || options.Server == "" { - return DNSQueryOptions{}, nil - } - transportManager := service.FromContext[DNSTransportManager](ctx) - transport, loaded := transportManager.Transport(options.Server) - if !loaded { - return DNSQueryOptions{}, E.New("domain resolver not found: " + options.Server) - } - return DNSQueryOptions{ - Transport: transport, - Strategy: C.DomainStrategy(options.Strategy), - DisableCache: options.DisableCache, - DisableOptimisticCache: options.DisableOptimisticCache, - RewriteTTL: options.RewriteTTL, - Timeout: time.Duration(options.Timeout), - ClientSubnet: options.ClientSubnet.Build(netip.Prefix{}), - }, nil -} - type RDRCStore interface { LoadRDRC(transportName string, qName string, qType uint16) (rejected bool) SaveRDRC(transportName string, qName string, qType uint16) error diff --git a/common/dialer/dialer.go b/common/dialer/dialer.go index f0e80430..7c0b9b5a 100644 --- a/common/dialer/dialer.go +++ b/common/dialer/dialer.go @@ -63,75 +63,31 @@ func NewWithOptions(options Options) (N.Dialer, error) { } } if options.RemoteIsDomain && (!hasDetour || options.ResolverOnDetour || dialOptions.DomainResolver != nil && dialOptions.DomainResolver.Server != "") { - networkManager := service.FromContext[adapter.NetworkManager](options.Context) - dnsTransport := service.FromContext[adapter.DNSTransportManager](options.Context) - var defaultOptions adapter.NetworkOptions - if networkManager != nil { - defaultOptions = networkManager.DefaultOptions() - } var ( - server string - dnsQueryOptions adapter.DNSQueryOptions - resolveFallbackDelay time.Duration + server string + dnsQueryOptions adapter.DNSQueryOptions ) - if dialOptions.DomainResolver != nil && dialOptions.DomainResolver.Server != "" { - var transport adapter.DNSTransport - if !options.DirectResolver { - var loaded bool - transport, loaded = dnsTransport.Transport(dialOptions.DomainResolver.Server) - if !loaded { - return nil, E.New("domain resolver not found: " + dialOptions.DomainResolver.Server) - } + hasDomainResolver := dialOptions.DomainResolver != nil && dialOptions.DomainResolver.Server != "" + if options.DirectResolver { + if !hasDomainResolver { + return nil, E.New("missing domain resolver for domain server address") } - var strategy C.DomainStrategy - if dialOptions.DomainResolver.Strategy != option.DomainStrategy(C.DomainStrategyAsIS) { - strategy = C.DomainStrategy(dialOptions.DomainResolver.Strategy) - } else if - //nolint:staticcheck - dialOptions.DomainStrategy != option.DomainStrategy(C.DomainStrategyAsIS) { - //nolint:staticcheck - strategy = C.DomainStrategy(dialOptions.DomainStrategy) - deprecated.Report(options.Context, deprecated.OptionLegacyDomainStrategyOptions) - } - server = dialOptions.DomainResolver.Server - dnsQueryOptions = adapter.DNSQueryOptions{ - Transport: transport, - Strategy: strategy, - Timeout: time.Duration(dialOptions.DomainResolver.Timeout), - DisableCache: dialOptions.DomainResolver.DisableCache, - DisableOptimisticCache: dialOptions.DomainResolver.DisableOptimisticCache, - RewriteTTL: dialOptions.DomainResolver.RewriteTTL, - ClientSubnet: dialOptions.DomainResolver.ClientSubnet.Build(netip.Prefix{}), - } - resolveFallbackDelay = time.Duration(dialOptions.FallbackDelay) - } else if options.DirectResolver { - return nil, E.New("missing domain resolver for domain server address") + dnsQueryOptions = domainResolveQueryOptions(dialOptions.DomainResolver) } else { - if defaultOptions.DomainResolver != "" { - dnsQueryOptions = defaultOptions.DomainResolveOptions - transport, loaded := dnsTransport.Transport(defaultOptions.DomainResolver) - if !loaded { - return nil, E.New("default domain resolver not found: " + defaultOptions.DomainResolver) - } - dnsQueryOptions.Transport = transport - resolveFallbackDelay = time.Duration(dialOptions.FallbackDelay) - } else { - transports := dnsTransport.Transports() - if len(transports) < 2 { - dnsQueryOptions.Transport = dnsTransport.Default() - } else if options.NewDialer { - return nil, E.New("missing domain resolver for domain server address") - } else { - deprecated.Report(options.Context, deprecated.OptionMissingDomainResolver) - } + dnsQueryOptions, err = NewDNSQueryOptions(options.Context, dialOptions.DomainResolver, options.NewDialer) + if err != nil { + return nil, err } - if + } + if hasDomainResolver { + server = dialOptions.DomainResolver.Server + } + if + //nolint:staticcheck + dialOptions.DomainStrategy != option.DomainStrategy(C.DomainStrategyAsIS) && (!hasDomainResolver || dialOptions.DomainResolver.Strategy == option.DomainStrategy(C.DomainStrategyAsIS)) { //nolint:staticcheck - dialOptions.DomainStrategy != option.DomainStrategy(C.DomainStrategyAsIS) { - //nolint:staticcheck - dnsQueryOptions.Strategy = C.DomainStrategy(dialOptions.DomainStrategy) - deprecated.Report(options.Context, deprecated.OptionLegacyDomainStrategyOptions) - } + dnsQueryOptions.Strategy = C.DomainStrategy(dialOptions.DomainStrategy) + deprecated.Report(options.Context, deprecated.OptionLegacyDomainStrategyOptions) } dialer = NewResolveDialer( options.Context, @@ -139,12 +95,54 @@ func NewWithOptions(options Options) (N.Dialer, error) { dialOptions.Detour == "" && !dialOptions.TCPFastOpen, server, dnsQueryOptions, - resolveFallbackDelay, + time.Duration(dialOptions.FallbackDelay), ) } return dialer, nil } +func NewDNSQueryOptions(ctx context.Context, domainResolver *option.DomainResolveOptions, newDialer bool) (adapter.DNSQueryOptions, error) { + dnsTransport := service.FromContext[adapter.DNSTransportManager](ctx) + if domainResolver != nil && domainResolver.Server != "" { + transport, loaded := dnsTransport.Transport(domainResolver.Server) + if !loaded { + return adapter.DNSQueryOptions{}, E.New("domain resolver not found: ", domainResolver.Server) + } + dnsQueryOptions := domainResolveQueryOptions(domainResolver) + dnsQueryOptions.Transport = transport + return dnsQueryOptions, nil + } + defaultOptions := service.FromContext[adapter.NetworkManager](ctx).DefaultOptions() + if defaultOptions.DomainResolver != "" { + transport, loaded := dnsTransport.Transport(defaultOptions.DomainResolver) + if !loaded { + return adapter.DNSQueryOptions{}, E.New("default domain resolver not found: ", defaultOptions.DomainResolver) + } + dnsQueryOptions := defaultOptions.DomainResolveOptions + dnsQueryOptions.Transport = transport + return dnsQueryOptions, nil + } + if len(dnsTransport.Transports()) < 2 { + return adapter.DNSQueryOptions{Transport: dnsTransport.Default()}, nil + } + if newDialer { + return adapter.DNSQueryOptions{}, E.New("missing domain resolver for domain server address") + } + deprecated.Report(ctx, deprecated.OptionMissingDomainResolver) + return adapter.DNSQueryOptions{}, nil +} + +func domainResolveQueryOptions(domainResolver *option.DomainResolveOptions) adapter.DNSQueryOptions { + return adapter.DNSQueryOptions{ + Strategy: C.DomainStrategy(domainResolver.Strategy), + Timeout: time.Duration(domainResolver.Timeout), + DisableCache: domainResolver.DisableCache, + DisableOptimisticCache: domainResolver.DisableOptimisticCache, + RewriteTTL: domainResolver.RewriteTTL, + ClientSubnet: domainResolver.ClientSubnet.Build(netip.Prefix{}), + } +} + type ParallelInterfaceDialer interface { N.Dialer DialParallelInterface(ctx context.Context, network string, destination M.Socksaddr, strategy *C.NetworkStrategy, interfaceType []C.InterfaceType, fallbackInterfaceType []C.InterfaceType, fallbackDelay time.Duration) (net.Conn, error) diff --git a/option/hysteria2.go b/option/hysteria2.go index 68832de9..237109b2 100644 --- a/option/hysteria2.go +++ b/option/hysteria2.go @@ -3,6 +3,7 @@ package option import ( "net/url" "reflect" + "slices" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/schema" @@ -10,6 +11,7 @@ import ( "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" "github.com/sagernet/sing/common/json/badoption" + M "github.com/sagernet/sing/common/metadata" ) type Hysteria2InboundOptions struct { @@ -37,6 +39,12 @@ type Hysteria2Realm struct { HTTPClient *HTTPClientOptions `json:"http_client,omitempty"` } +func (r Hysteria2Realm) STUNServersIsDomain() bool { + return slices.ContainsFunc(r.STUNServers, func(server string) bool { + return M.ParseSocksaddr(server).IsDomain() + }) +} + type Hysteria2RealmPortMapping struct { Enabled bool `json:"enabled,omitempty"` Timeout badoption.Duration `json:"timeout,omitempty"` diff --git a/protocol/hysteria2/inbound.go b/protocol/hysteria2/inbound.go index ef9233f3..977b5a9c 100644 --- a/protocol/hysteria2/inbound.go +++ b/protocol/hysteria2/inbound.go @@ -12,6 +12,7 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/dialer" "github.com/sagernet/sing-box/common/listener" "github.com/sagernet/sing-box/common/tls" C "github.com/sagernet/sing-box/constant" @@ -141,11 +142,15 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo return nil, E.New("realm.ip_version 4 conflicts with listen address ", listenAddr) } } - queryOptions, err := adapter.DNSQueryOptionsFrom(ctx, options.Realm.STUNDomainResolver) - if err != nil { - return nil, err + var queryOptions adapter.DNSQueryOptions + if options.Realm.STUNServersIsDomain() { + queryOptions, err = dialer.NewDNSQueryOptions(ctx, options.Realm.STUNDomainResolver, true) + if err != nil { + return nil, E.Cause(err, "create realm STUN domain resolver") + } } - httpClientTransport, err := service.FromContext[adapter.HTTPClientManager](ctx).ResolveTransport(ctx, logger, common.PtrValueOrDefault(options.Realm.HTTPClient)) + var httpClientTransport adapter.HTTPTransport + httpClientTransport, err = service.FromContext[adapter.HTTPClientManager](ctx).ResolveTransport(ctx, logger, common.PtrValueOrDefault(options.Realm.HTTPClient)) if err != nil { return nil, E.Cause(err, "create realm http client") } diff --git a/protocol/hysteria2/outbound.go b/protocol/hysteria2/outbound.go index 6576bf9f..e235a472 100644 --- a/protocol/hysteria2/outbound.go +++ b/protocol/hysteria2/outbound.go @@ -76,21 +76,25 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL return nil, E.New("unknown obfs type: ", options.Obfs.Type) } } + realmSTUNServersIsDomain := options.Realm != nil && options.Realm.STUNServersIsDomain() outboundDialer, err := dialer.NewWithOptions(dialer.Options{ - Context: ctx, - Options: options.DialerOptions, - RemoteIsDomain: options.ServerIsDomain(), + Context: ctx, + Options: options.DialerOptions, + RemoteIsDomain: options.ServerIsDomain() || realmSTUNServersIsDomain, + ResolverOnDetour: realmSTUNServersIsDomain, + NewDialer: realmSTUNServersIsDomain, }) if err != nil { return nil, err } var realmOptions *realm.Options if options.Realm != nil { - queryOptions, err := adapter.DNSQueryOptionsFrom(ctx, options.DialerOptions.DomainResolver) - if err != nil { - return nil, err + var queryOptions adapter.DNSQueryOptions + if realmSTUNServersIsDomain { + queryOptions = outboundDialer.(dialer.ResolveDialer).QueryOptions() } - httpClientTransport, err := service.FromContext[adapter.HTTPClientManager](ctx).ResolveTransport(ctx, logger, common.PtrValueOrDefault(options.Realm.HTTPClient)) + var httpClientTransport adapter.HTTPTransport + httpClientTransport, err = service.FromContext[adapter.HTTPClientManager](ctx).ResolveTransport(ctx, logger, common.PtrValueOrDefault(options.Realm.HTTPClient)) if err != nil { return nil, E.Cause(err, "create realm http client") } From 83aec4222fc95b549716d3a28a127a975b94db97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 24 Sep 2026 10:26:45 +0800 Subject: [PATCH 34/66] Fix missing handshake success report for hijack-dns --- route/dns.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/route/dns.go b/route/dns.go index 5aaf4b1b..d697e787 100644 --- a/route/dns.go +++ b/route/dns.go @@ -20,9 +20,13 @@ import ( func (r *Router) hijackDNSStream(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { r.searchProcessInfo(ctx, &metadata) metadata.Destination = M.Socksaddr{} + err := N.ReportConnHandshakeSuccess(conn, conn) + if err != nil { + return E.Cause(err, "report handshake success") + } for { conn.SetReadDeadline(time.Now().Add(C.DNSTimeout)) - err := dnsOutbound.HandleStreamDNSRequest(ctx, r.dns, conn, metadata) + err = dnsOutbound.HandleStreamDNSRequest(ctx, r.dns, conn, metadata) if err != nil { if !E.IsClosedOrCanceled(err) { return err @@ -35,7 +39,13 @@ func (r *Router) hijackDNSStream(ctx context.Context, conn net.Conn, metadata ad func (r *Router) hijackDNSPacket(ctx context.Context, conn N.PacketConn, packetBuffers []*N.PacketBuffer, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) error { r.searchProcessInfo(ctx, &metadata) - err := dnsOutbound.NewDNSPacketConnection(ctx, r.dns, conn, packetBuffers, metadata) + err := N.ReportPacketConnHandshakeSuccess(conn, nil) + if err != nil { + N.ReleaseMultiPacketBuffer(packetBuffers) + err = E.Cause(err, "report handshake success") + } else { + err = dnsOutbound.NewDNSPacketConnection(ctx, r.dns, conn, packetBuffers, metadata) + } N.CloseOnHandshakeFailure(conn, onClose, err) if err != nil && !E.IsClosedOrCanceled(err) { return E.Cause(err, "process DNS packet") From e6a1a3073e8b6779ea57603b69600388ee89a29a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 24 Sep 2026 16:17:51 +0800 Subject: [PATCH 35/66] Fix hysteria port hopping connecting to another resolved server address --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ee4c4df4..b2ab87ca 100644 --- a/go.mod +++ b/go.mod @@ -50,7 +50,7 @@ require ( github.com/sagernet/sing-mux v0.3.8 github.com/sagernet/sing-openconnect v0.0.0-20260810065514-53aa8058f8df github.com/sagernet/sing-openvpn v0.0.0-20260729104525-103eb5fe5eb6 - github.com/sagernet/sing-quic v0.7.0 + github.com/sagernet/sing-quic v0.7.1-0.20260924092235-6a3a24d65b99 github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1 diff --git a/go.sum b/go.sum index 28865946..3172be80 100644 --- a/go.sum +++ b/go.sum @@ -328,8 +328,8 @@ github.com/sagernet/sing-openconnect v0.0.0-20260810065514-53aa8058f8df h1:ogyC7 github.com/sagernet/sing-openconnect v0.0.0-20260810065514-53aa8058f8df/go.mod h1:4AKZLVcvY3r54UaK2Gbnm7aN8pOwdLz+y4EP0QFZ5Eg= github.com/sagernet/sing-openvpn v0.0.0-20260729104525-103eb5fe5eb6 h1:ZAvhkor0prJ8KEX9EmTEH+gJ1WlA3ffjS8GZT6KKq9c= github.com/sagernet/sing-openvpn v0.0.0-20260729104525-103eb5fe5eb6/go.mod h1:PWX7WygD8jpwfqfaGNySXpJYTn0SOwjBI1BKHHC2+Bw= -github.com/sagernet/sing-quic v0.7.0 h1:dgbJdRYsa9DXbgjchnuVf2xhAvjY4/jylWdNNVZWmRU= -github.com/sagernet/sing-quic v0.7.0/go.mod h1:rJFeVxbEej2iuyeCJ3drKW+p5rOgtLIW3XhZ6ty7Fig= +github.com/sagernet/sing-quic v0.7.1-0.20260924092235-6a3a24d65b99 h1:zxN7hz90xUa03Nbrnp6nutArlrQaVHOOvbTYVGx4/PI= +github.com/sagernet/sing-quic v0.7.1-0.20260924092235-6a3a24d65b99/go.mod h1:rJFeVxbEej2iuyeCJ3drKW+p5rOgtLIW3XhZ6ty7Fig= 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= From af6e64c3b69e6132ebaee0e1a3d24e93903f6709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 24 Sep 2026 17:50:23 +0800 Subject: [PATCH 36/66] Bump version --- clients/android | 2 +- clients/apple | 2 +- clients/desktop | 2 +- docs/changelog.md | 4 ++++ 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/clients/android b/clients/android index a3668ae6..fc21909d 160000 --- a/clients/android +++ b/clients/android @@ -1 +1 @@ -Subproject commit a3668ae6e4bbcb3ceff8461d0cac55d79edf504f +Subproject commit fc21909df7a3f0fc9435f3866fb6a4960711aa5f diff --git a/clients/apple b/clients/apple index 2b1763a8..9f828a26 160000 --- a/clients/apple +++ b/clients/apple @@ -1 +1 @@ -Subproject commit 2b1763a80f2c1dee1ab3ac62d84dbda7dc5178f4 +Subproject commit 9f828a26c8ef437ffc0660693acf794a1649bd5e diff --git a/clients/desktop b/clients/desktop index fb1175ca..32f915ba 160000 --- a/clients/desktop +++ b/clients/desktop @@ -1 +1 @@ -Subproject commit fb1175ca3b00bfc676c4b4b2b25f5eacb6a0c7ae +Subproject commit 32f915ba595601dbc2dd346c33fe9fedd3e72979 diff --git a/docs/changelog.md b/docs/changelog.md index 35bfd648..9c7de2cf 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,10 @@ icon: material/alert-decagram --- +#### 1.14.2 + +* Fixes and improvements + #### 1.14.1 * Fixes and improvements From 4537a1ac0023a5fc7816dc64915c67ad5253b6c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 25 Sep 2026 19:28:45 +0800 Subject: [PATCH 37/66] Fix system TUN read loop stopping on write errors --- transport/openconnect/device_system.go | 9 +++------ transport/openvpn/device_system.go | 9 +++------ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/transport/openconnect/device_system.go b/transport/openconnect/device_system.go index 0750dc73..ac96666c 100644 --- a/transport/openconnect/device_system.go +++ b/transport/openconnect/device_system.go @@ -152,8 +152,7 @@ func (d *systemDevice) readLoop(tunInterface tun.Tun, mtu int) { err = d.writeOutbound([]*buf.Buffer{packetBuffer}) packetBuffer.DecRef() if err != nil { - d.options.Logger.Error(E.Cause(err, "write packet")) - return + d.options.Logger.Debug(E.Cause(err, "write packet")) } } } @@ -183,8 +182,7 @@ func (d *systemDevice) readLoopLinux(tunInterface tun.LinuxTUN, batchSize int, m packetBuffers[i].DecRef() } if writeErr != nil { - d.options.Logger.Error(E.Cause(writeErr, "write packet batch")) - return + d.options.Logger.Debug(E.Cause(writeErr, "write packet batch")) } } if readErr != nil { @@ -211,8 +209,7 @@ func (d *systemDevice) readLoopDarwin(tunInterface tun.DarwinTUN) { if len(outboundBuffers) > 0 { writeErr := d.writeOutbound(outboundBuffers) if writeErr != nil { - d.options.Logger.Error(E.Cause(writeErr, "write packet batch")) - return + d.options.Logger.Debug(E.Cause(writeErr, "write packet batch")) } } if readErr != nil { diff --git a/transport/openvpn/device_system.go b/transport/openvpn/device_system.go index 728626a9..ed393e30 100644 --- a/transport/openvpn/device_system.go +++ b/transport/openvpn/device_system.go @@ -155,8 +155,7 @@ func (d *systemDevice) readLoop(tunInterface tun.Tun, mtu int) { err = d.writeOutbound([]*buf.Buffer{packetBuffer}) packetBuffer.DecRef() if err != nil { - d.options.Logger.Error(E.Cause(err, "write packet")) - return + d.options.Logger.Debug(E.Cause(err, "write packet")) } } } @@ -193,8 +192,7 @@ func (d *systemDevice) readLoopLinux(tunInterface tun.LinuxTUN, batchSize int, m packetBuffer.DecRef() } if writeErr != nil { - d.options.Logger.Error(E.Cause(writeErr, "write packet batch")) - return + d.options.Logger.Debug(E.Cause(writeErr, "write packet batch")) } } if readErr != nil { @@ -226,8 +224,7 @@ func (d *systemDevice) readLoopDarwin(tunInterface tun.DarwinTUN) { if len(outboundBuffers) > 0 { writeErr := d.writeOutbound(outboundBuffers) if writeErr != nil { - d.options.Logger.Error(E.Cause(writeErr, "write packet batch")) - return + d.options.Logger.Debug(E.Cause(writeErr, "write packet batch")) } } if readErr != nil { From ddbf34639a630519c9508e89904d33b3fb83808e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 25 Sep 2026 20:13:01 +0800 Subject: [PATCH 38/66] Fix local DNS server ignoring systemd-resolved global DNS servers --- dns/transport/exchange_strategy.go | 64 +++ dns/transport/local/local_resolved.go | 1 - dns/transport/local/local_resolved_linux.go | 593 +++++++++++++------- 3 files changed, 466 insertions(+), 192 deletions(-) diff --git a/dns/transport/exchange_strategy.go b/dns/transport/exchange_strategy.go index c85d3bb6..633c7fce 100644 --- a/dns/transport/exchange_strategy.go +++ b/dns/transport/exchange_strategy.go @@ -82,6 +82,70 @@ type sequentialCallState struct { continued bool } +func ExchangeParallel(ctx context.Context, exchangers []AsyncExchanger, accept func(response *mDNS.Msg, err error) bool, callback func(response *mDNS.Msg, err error)) { + if len(exchangers) == 0 { + callback(nil, E.New("missing exchangers")) + return + } + exchangeCtx, cancel := context.WithCancel(ctx) + parallel := ¶llelExchange{ + accept: accept, + callback: callback, + cancel: cancel, + responses: make([]*mDNS.Msg, len(exchangers)), + errors: make([]error, len(exchangers)), + pending: len(exchangers), + } + for i, exchanger := range exchangers { + exchanger(exchangeCtx, func(response *mDNS.Msg, err error) { + parallel.complete(i, response, err) + }) + } +} + +type parallelExchange struct { + access sync.Mutex + accept func(response *mDNS.Msg, err error) bool + callback func(response *mDNS.Msg, err error) + cancel context.CancelFunc + responses []*mDNS.Msg + errors []error + pending int + completed bool +} + +func (p *parallelExchange) complete(index int, response *mDNS.Msg, err error) { + p.access.Lock() + if p.completed { + p.access.Unlock() + return + } + accepted := p.accept(response, err) + if !accepted { + p.responses[index] = response + p.errors[index] = err + p.pending-- + if p.pending > 0 { + p.access.Unlock() + return + } + } + p.completed = true + p.access.Unlock() + p.cancel() + if accepted { + p.callback(response, err) + return + } + for i, finalResponse := range p.responses { + if p.errors[i] == nil { + p.callback(finalResponse, nil) + return + } + } + p.callback(nil, E.Errors(p.errors...)) +} + func ExchangeNames(ctx context.Context, names []string, question mDNS.Question, exchangerFor func(fqdn string) AsyncExchanger, callback func(response *mDNS.Msg, err error)) { if len(names) == 0 { callback(nil, E.New("missing name candidates")) diff --git a/dns/transport/local/local_resolved.go b/dns/transport/local/local_resolved.go index b698f6a9..efd5eabd 100644 --- a/dns/transport/local/local_resolved.go +++ b/dns/transport/local/local_resolved.go @@ -11,6 +11,5 @@ type ResolvedResolver interface { Close() error Reset() Environment() []string - Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) } diff --git a/dns/transport/local/local_resolved_linux.go b/dns/transport/local/local_resolved_linux.go index 50d4abce..6a7eb3cb 100644 --- a/dns/transport/local/local_resolved_linux.go +++ b/dns/transport/local/local_resolved_linux.go @@ -6,9 +6,11 @@ import ( "errors" "net/netip" "os" + "slices" "strings" "sync" "sync/atomic" + "time" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/dialer" @@ -17,7 +19,6 @@ import ( "github.com/sagernet/sing-box/dns" dnsTransport "github.com/sagernet/sing-box/dns/transport" "github.com/sagernet/sing-box/option" - "github.com/sagernet/sing-box/service/resolved" "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/control" @@ -54,8 +55,11 @@ func isSystemdResolvedManaged() bool { type DBusResolvedResolver struct { ctx context.Context logger logger.ContextLogger + interfaceFinder control.InterfaceFinder interfaceMonitor tun.DefaultInterfaceMonitor interfaceCallback *list.Element[tun.DefaultInterfaceUpdateCallback] + networkMonitor tun.NetworkUpdateMonitor + networkCallback *list.Element[tun.NetworkUpdateCallback] systemBus *dbus.Conn savedServerSet atomic.Pointer[resolvedServerSet] updateAccess sync.Mutex @@ -66,23 +70,88 @@ type DBusResolvedResolver struct { } type resolvedServerSet struct { - servers []resolvedServer + scopes []*resolvedScope signature []string } +// Match levels of dns_scope_good_domain() in systemd-resolved: a routing or search +// domain match yields its label count, and only scopes at the best level are queried. +const ( + resolvedScopeNoMatch = -3 + resolvedScopeLastResort = -2 + resolvedScopeMaybe = -1 +) + +// systemd-resolved only signals changes of the DNS server list, while clients such as +// NetworkManager and systemd-networkd configure domains, default route and DNS over TLS +// of a link in separate calls right after it; link state changes are not signaled at all. +const resolvedUpdateDelay = time.Second + +// SD_RESOLVED_DNS bit of org.freedesktop.resolve1.Link.ScopesMask, set when the link has a unicast DNS scope. +const resolvedScopesMaskDNS = 1 << 0 + +// TRANSACTION_UDP_TIMEOUT_USEC in systemd-resolved (resolved-timeouts.h), after which a transaction +// is resent to the next server of the scope. +const resolvedServerTimeout = 5 * time.Second + +type resolvedScope struct { + domains []string + defaultRoute bool + fallback bool + servers []resolvedServer + currentServer atomic.Uint32 +} + type resolvedServer struct { primaryTransport adapter.DNSTransport fallbackTransport adapter.DNSTransport } +type resolvedScopeSpecification struct { + interfaceName string + dnsOverTLSMode string + domains []string + defaultRoute bool + fallback bool + servers []resolvedServerSpecification +} + type resolvedServerSpecification struct { address netip.Addr port uint16 serverName string } +type resolvedManagerDNS struct { + InterfaceIndex int32 + Family int32 + Address []byte +} + +type resolvedManagerDNSEx struct { + InterfaceIndex int32 + Family int32 + Address []byte + Port uint16 + Name string +} + +type resolvedManagerDomain struct { + InterfaceIndex int32 + Domain string + RoutingOnly bool +} + +type resolvedManagerServer struct { + interfaceIndex int32 + address []byte + port uint16 + serverName string +} + func NewResolvedResolver(ctx context.Context, logger logger.ContextLogger) (ResolvedResolver, error) { - interfaceMonitor := service.FromContext[adapter.NetworkManager](ctx).InterfaceMonitor() + networkManager := service.FromContext[adapter.NetworkManager](ctx) + interfaceMonitor := networkManager.InterfaceMonitor() if interfaceMonitor == nil { return nil, os.ErrInvalid } @@ -93,7 +162,9 @@ func NewResolvedResolver(ctx context.Context, logger logger.ContextLogger) (Reso return &DBusResolvedResolver{ ctx: ctx, logger: logger, + interfaceFinder: networkManager.InterfaceFinder(), interfaceMonitor: interfaceMonitor, + networkMonitor: networkManager.NetworkMonitor(), systemBus: systemBus, }, nil } @@ -101,6 +172,9 @@ func NewResolvedResolver(ctx context.Context, logger logger.ContextLogger) (Reso func (t *DBusResolvedResolver) Start() error { t.updateStatus(t.ctx) t.interfaceCallback = t.interfaceMonitor.RegisterCallback(t.updateDefaultInterface) + if t.networkMonitor != nil { + t.networkCallback = t.networkMonitor.RegisterCallback(t.postUpdateStatus) + } err := t.systemBus.BusObject().AddMatchSignal( "org.freedesktop.DBus", "NameOwnerChanged", @@ -143,6 +217,9 @@ func (t *DBusResolvedResolver) Close() error { if t.interfaceCallback != nil { t.interfaceMonitor.UnregisterCallback(t.interfaceCallback) } + if t.networkCallback != nil { + t.networkMonitor.UnregisterCallback(t.networkCallback) + } if t.systemBus != nil { _ = t.systemBus.Close() } @@ -155,10 +232,12 @@ func (t *DBusResolvedResolver) Reset() { if serverSet == nil { return } - for _, server := range serverSet.servers { - server.primaryTransport.Reset() - if server.fallbackTransport != nil { - server.fallbackTransport.Reset() + for _, scope := range serverSet.scopes { + for _, server := range scope.servers { + server.primaryTransport.Reset() + if server.fallbackTransport != nil { + server.fallbackTransport.Reset() + } } } } @@ -171,40 +250,20 @@ func (t *DBusResolvedResolver) Environment() []string { return serverSet.signature } -func (t *DBusResolvedResolver) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - serverSet := t.savedServerSet.Load() - if serverSet == nil { - var err error - serverSet, err = t.checkResolved(context.Background()) - if err != nil { - return nil, err - } - previousServerSet := t.savedServerSet.Swap(serverSet) - if previousServerSet != nil { - _ = previousServerSet.Close() - } - } - response, err := t.exchangeServerSet(ctx, message, serverSet) - if err == nil { - return response, nil - } - t.updateStatus(t.ctx) - refreshedServerSet := t.savedServerSet.Load() - if refreshedServerSet == nil || refreshedServerSet == serverSet { - return nil, err - } - return t.exchangeServerSet(ctx, message, refreshedServerSet) -} - func (t *DBusResolvedResolver) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { serverSet := t.savedServerSet.Load() if serverSet == nil { go func() { - callback(t.Exchange(ctx, message)) + err := t.updateStatus(t.ctx) + if err != nil { + callback(nil, err) + return + } + t.exchangeServerSet(ctx, message, t.savedServerSet.Load(), callback) }() return } - t.exchangeServerSetAsync(ctx, message, serverSet, func(response *mDNS.Msg, err error) { + t.exchangeServerSet(ctx, message, serverSet, func(response *mDNS.Msg, err error) { if err == nil { callback(response, nil) return @@ -216,29 +275,129 @@ func (t *DBusResolvedResolver) ExchangeAsync(ctx context.Context, message *mDNS. callback(nil, err) return } - t.exchangeServerSetAsync(ctx, message, refreshedServerSet, callback) + t.exchangeServerSet(ctx, message, refreshedServerSet, callback) }() }) } -func (t *DBusResolvedResolver) exchangeServerSetAsync(ctx context.Context, message *mDNS.Msg, serverSet *resolvedServerSet, callback func(response *mDNS.Msg, err error)) { - if len(serverSet.servers) == 0 { - callback(nil, E.New("link has no DNS servers configured")) +func (t *DBusResolvedResolver) exchangeServerSet(ctx context.Context, message *mDNS.Msg, serverSet *resolvedServerSet, callback func(response *mDNS.Msg, err error)) { + if serverSet == nil { + callback(nil, os.ErrClosed) return } - serverExchangers := make([]dnsTransport.AsyncExchanger, 0, len(serverSet.servers)) - for _, server := range serverSet.servers { + scopes := serverSet.selectScopes(message.Question[0].Name) + if len(scopes) == 0 { + callback(nil, E.New("no appropriate name servers or networks for name found")) + return + } + scopeExchangers := common.Map(scopes, func(scope *resolvedScope) dnsTransport.AsyncExchanger { + return func(exchangeCtx context.Context, exchangeCallback func(response *mDNS.Msg, err error)) { + scope.exchange(exchangeCtx, message, exchangeCallback) + } + }) + dnsTransport.ExchangeParallel(ctx, scopeExchangers, acceptResolvedScopeResponse, callback) +} + +func (s *resolvedScope) exchange(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + serverCount := uint32(len(s.servers)) + currentServer := s.currentServer.Load() + serverExchangers := make([]dnsTransport.AsyncExchanger, 0, serverCount) + for i := range serverCount { + serverIndex := (currentServer + i) % serverCount + server := s.servers[serverIndex] serverExchangers = append(serverExchangers, func(exchangeCtx context.Context, exchangeCallback func(response *mDNS.Msg, err error)) { - server.primaryTransport.ExchangeAsync(exchangeCtx, message, func(response *mDNS.Msg, exchangeErr error) { - if exchangeErr != nil && server.fallbackTransport != nil { - server.fallbackTransport.ExchangeAsync(exchangeCtx, message, exchangeCallback) - return + server.exchange(exchangeCtx, message, func(response *mDNS.Msg, err error) { + if exchangeCtx.Err() == nil && !acceptResolvedServerResponse(response, err) { + s.currentServer.CompareAndSwap(serverIndex, (serverIndex+1)%serverCount) } - exchangeCallback(response, exchangeErr) + exchangeCallback(response, err) }) }) } - dnsTransport.ExchangeSequential(ctx, serverExchangers, nil, callback) + dnsTransport.ExchangeSequential(ctx, serverExchangers, acceptResolvedServerResponse, callback) +} + +func (s *resolvedServer) exchange(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + exchangeResolvedTransport(ctx, s.primaryTransport, message, func(response *mDNS.Msg, err error) { + if err != nil && s.fallbackTransport != nil { + exchangeResolvedTransport(ctx, s.fallbackTransport, message, callback) + return + } + callback(response, err) + }) +} + +func exchangeResolvedTransport(ctx context.Context, transport adapter.DNSTransport, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + exchangeCtx, cancel := context.WithTimeout(ctx, resolvedServerTimeout) + transport.ExchangeAsync(exchangeCtx, message, func(response *mDNS.Msg, err error) { + cancel() + callback(response, err) + }) +} + +// systemd-resolved starts a query on every scope at the best match level at once +// (resolved-dns-query.c dns_query_go_scopes) and completes it with the first positive answer +// (dns_query_ready). Within a scope, SERVFAIL and timeouts move the scope to its next server, which +// later queries start from (resolved-dns-transaction.c dns_transaction_retry). +func acceptResolvedServerResponse(response *mDNS.Msg, err error) bool { + return err == nil && response.Rcode != mDNS.RcodeServerFailure +} + +func acceptResolvedScopeResponse(response *mDNS.Msg, err error) bool { + return err == nil && response.Rcode == mDNS.RcodeSuccess +} + +func (s *resolvedServerSet) selectScopes(name string) []*resolvedScope { + var ( + bestMatch = resolvedScopeNoMatch + selectedScopes []*resolvedScope + ) + for _, scope := range s.scopes { + match := scope.match(name) + if match == resolvedScopeNoMatch || match < bestMatch { + continue + } + if match > bestMatch { + bestMatch = match + selectedScopes = selectedScopes[:0] + } + selectedScopes = append(selectedScopes, scope) + } + return selectedScopes +} + +func (s *resolvedScope) match(name string) int { + bestLabels := -1 + for _, domain := range s.domains { + if mDNS.IsSubDomain(domain, name) { + bestLabels = max(bestLabels, mDNS.CountLabel(domain)) + } + } + if bestLabels >= 0 { + return bestLabels + } + if !s.defaultRoute { + return resolvedScopeNoMatch + } + if s.fallback { + return resolvedScopeLastResort + } + return resolvedScopeMaybe +} + +func (s *resolvedServerSet) Close() error { + return E.Errors(common.Map(s.scopes, (*resolvedScope).Close)...) +} + +func (s *resolvedScope) Close() error { + var errors []error + for _, server := range s.servers { + errors = append(errors, server.primaryTransport.Close()) + if server.fallbackTransport != nil { + errors = append(errors, server.fallbackTransport.Close()) + } + } + return E.Errors(errors...) } func (t *DBusResolvedResolver) loopUpdateStatus() { @@ -275,22 +434,35 @@ func (t *DBusResolvedResolver) postUpdateStatus() { } go func() { defer updateCancel() + timer := time.NewTimer(resolvedUpdateDelay) + defer timer.Stop() + select { + case <-timer.C: + case <-updateContext.Done(): + return + } t.updateStatus(updateContext) }() } -func (t *DBusResolvedResolver) updateStatus(ctx context.Context) { +func (t *DBusResolvedResolver) updateStatus(ctx context.Context) error { t.updateRunAccess.Lock() defer t.updateRunAccess.Unlock() - if t.closed || ctx.Err() != nil { - return + if t.closed { + return os.ErrClosed + } + if ctx.Err() != nil { + return ctx.Err() } serverSet, err := t.checkResolved(ctx) if t.closed || ctx.Err() != nil { if serverSet != nil { _ = serverSet.Close() } - return + if t.closed { + return os.ErrClosed + } + return ctx.Err() } oldServerSet := t.savedServerSet.Swap(serverSet) if oldServerSet != nil { @@ -304,34 +476,16 @@ func (t *DBusResolvedResolver) updateStatus(ctx context.Context) { if oldServerSet != nil { t.logger.Debug("systemd-resolved service is gone") } - return + return err } else if oldServerSet == nil { t.logger.Debug("using systemd-resolved service as resolver") } -} - -func (t *DBusResolvedResolver) exchangeServerSet(ctx context.Context, message *mDNS.Msg, serverSet *resolvedServerSet) (*mDNS.Msg, error) { - if serverSet == nil || len(serverSet.servers) == 0 { - return nil, E.New("link has no DNS servers configured") - } - var lastError error - for _, server := range serverSet.servers { - response, err := server.primaryTransport.Exchange(ctx, message) - if err != nil && server.fallbackTransport != nil { - response, err = server.fallbackTransport.Exchange(ctx, message) - } - if err != nil { - lastError = err - continue - } - return response, nil - } - return nil, lastError + return nil } func (t *DBusResolvedResolver) checkResolved(ctx context.Context) (*resolvedServerSet, error) { - dbusObject := t.systemBus.Object("org.freedesktop.resolve1", "/org/freedesktop/resolve1") - err := dbusObject.(*dbus.Object).CallWithContext(ctx, "org.freedesktop.DBus.Peer.Ping", 0).Err + managerObject := t.systemBus.Object("org.freedesktop.resolve1", "/org/freedesktop/resolve1") + err := managerObject.CallWithContext(ctx, "org.freedesktop.DBus.Peer.Ping", 0).Err if err != nil { return nil, err } @@ -339,97 +493,167 @@ func (t *DBusResolvedResolver) checkResolved(ctx context.Context) (*resolvedServ if defaultInterface == nil { return nil, E.New("missing default interface") } - call := dbusObject.(*dbus.Object).CallWithContext( - ctx, - "org.freedesktop.resolve1.Manager.GetLink", - 0, - int32(defaultInterface.Index), - ) - if call.Err != nil { - return nil, call.Err - } - var linkPath dbus.ObjectPath - err = call.Store(&linkPath) + managerProperties, err := loadResolvedProperties(ctx, managerObject, "org.freedesktop.resolve1.Manager") if err != nil { return nil, err } - linkObject := t.systemBus.Object("org.freedesktop.resolve1", linkPath) - if linkObject == nil { - return nil, E.New("missing link object for default interface") - } - dnsOverTLSMode, err := loadResolvedLinkDNSOverTLS(linkObject) + servers, err := loadResolvedManagerServers(managerProperties, "DNS") if err != nil { return nil, err } - err = ctx.Err() + var managerDomains []resolvedManagerDomain + err = storeResolvedProperty(managerProperties, "Domains", &managerDomains) if err != nil { return nil, err } - linkDNSEx, err := loadResolvedLinkDNSEx(linkObject) + var dnsOverTLSMode string + err = storeResolvedProperty(managerProperties, "DNSOverTLS", &dnsOverTLSMode) if err != nil { return nil, err } - err = ctx.Err() - if err != nil { - return nil, err + globalScope := resolvedScopeSpecification{ + interfaceName: defaultInterface.Name, + dnsOverTLSMode: dnsOverTLSMode, + defaultRoute: true, } - linkDNS, err := loadResolvedLinkDNS(linkObject) - if err != nil { - return nil, err + var linkScopes []resolvedScopeSpecification + linkScopeIndexes := make(map[int32]int) + for _, server := range servers { + if server.interfaceIndex == 0 { + serverSpecification, loaded := buildResolvedServerSpecification(globalScope.interfaceName, server) + if loaded { + globalScope.servers = append(globalScope.servers, serverSpecification) + } + continue + } + scopeIndex, loaded := linkScopeIndexes[server.interfaceIndex] + if !loaded { + linkScope, linkLoaded, linkErr := t.loadResolvedLinkScope(ctx, managerObject, server.interfaceIndex) + if linkErr != nil { + return nil, linkErr + } + if !linkLoaded { + linkScopeIndexes[server.interfaceIndex] = -1 + continue + } + scopeIndex = len(linkScopes) + linkScopeIndexes[server.interfaceIndex] = scopeIndex + linkScopes = append(linkScopes, linkScope) + } else if scopeIndex < 0 { + continue + } + serverSpecification, loaded := buildResolvedServerSpecification(linkScopes[scopeIndex].interfaceName, server) + if loaded { + linkScopes[scopeIndex].servers = append(linkScopes[scopeIndex].servers, serverSpecification) + } } - if len(linkDNSEx) == 0 && len(linkDNS) == 0 { - for _, inbound := range service.FromContext[adapter.InboundManager](t.ctx).Inbounds() { - if inbound.Type() == C.TypeTun { - return nil, E.New("No appropriate name servers or networks for name found") + for _, domain := range managerDomains { + domainName := mDNS.Fqdn(domain.Domain) + if domain.InterfaceIndex == 0 { + globalScope.domains = append(globalScope.domains, domainName) + continue + } + scopeIndex, loaded := linkScopeIndexes[domain.InterfaceIndex] + if loaded && scopeIndex >= 0 { + linkScopes[scopeIndex].domains = append(linkScopes[scopeIndex].domains, domainName) + } + } + if len(globalScope.servers) == 0 && !slices.ContainsFunc(linkScopes, func(it resolvedScopeSpecification) bool { + return it.defaultRoute && len(it.servers) > 0 + }) { + fallbackServers, fallbackErr := loadResolvedManagerServers(managerProperties, "FallbackDNS") + if fallbackErr != nil { + return nil, fallbackErr + } + for _, server := range fallbackServers { + serverSpecification, loaded := buildResolvedServerSpecification(globalScope.interfaceName, server) + if loaded { + globalScope.servers = append(globalScope.servers, serverSpecification) } } - return nil, E.New("link has no DNS servers configured") + globalScope.fallback = true + } + scopeSpecifications := common.Filter(append([]resolvedScopeSpecification{globalScope}, linkScopes...), func(it resolvedScopeSpecification) bool { + return len(it.servers) > 0 + }) + if len(scopeSpecifications) == 0 { + return nil, E.New("no DNS servers configured") + } + serverSet := &resolvedServerSet{} + for _, scopeSpecification := range scopeSpecifications { + scope, createErr := t.createResolvedScope(scopeSpecification) + if createErr != nil { + _ = serverSet.Close() + return nil, createErr + } + serverSet.scopes = append(serverSet.scopes, scope) + for _, serverSpecification := range scopeSpecification.servers { + serverSet.signature = append(serverSet.signature, scopeSpecification.interfaceName+"/"+M.SocksaddrFrom(serverSpecification.address, serverSpecification.port).String()) + } + } + return serverSet, nil +} + +func (t *DBusResolvedResolver) loadResolvedLinkScope(ctx context.Context, managerObject dbus.BusObject, interfaceIndex int32) (resolvedScopeSpecification, bool, error) { + linkInterface, err := t.interfaceFinder.ByIndex(int(interfaceIndex)) + if err != nil || slices.Contains(t.interfaceMonitor.MyInterfaces(), linkInterface.Name) { + return resolvedScopeSpecification{}, false, nil + } + var linkPath dbus.ObjectPath + err = managerObject.CallWithContext(ctx, "org.freedesktop.resolve1.Manager.GetLink", 0, interfaceIndex).Store(&linkPath) + if err != nil { + return resolvedScopeSpecification{}, false, err + } + linkProperties, err := loadResolvedProperties(ctx, t.systemBus.Object("org.freedesktop.resolve1", linkPath), "org.freedesktop.resolve1.Link") + if err != nil { + return resolvedScopeSpecification{}, false, err + } + var scopesMask uint64 + err = storeResolvedProperty(linkProperties, "ScopesMask", &scopesMask) + if err != nil { + return resolvedScopeSpecification{}, false, err + } + if scopesMask&resolvedScopesMaskDNS == 0 { + return resolvedScopeSpecification{}, false, nil + } + linkScope := resolvedScopeSpecification{ + interfaceName: linkInterface.Name, + } + err = storeResolvedProperty(linkProperties, "DNSOverTLS", &linkScope.dnsOverTLSMode) + if err != nil { + return resolvedScopeSpecification{}, false, err + } + err = storeResolvedProperty(linkProperties, "DefaultRoute", &linkScope.defaultRoute) + if err != nil { + return resolvedScopeSpecification{}, false, err + } + return linkScope, true, nil +} + +func (t *DBusResolvedResolver) createResolvedScope(scopeSpecification resolvedScopeSpecification) (*resolvedScope, error) { + scope := &resolvedScope{ + domains: scopeSpecification.domains, + defaultRoute: scopeSpecification.defaultRoute, + fallback: scopeSpecification.fallback, } serverDialer, err := dialer.NewDefault(t.ctx, option.DialerOptions{ AbstractDialerOptions: option.AbstractDialerOptions{ - BindInterface: defaultInterface.Name, + BindInterface: scopeSpecification.interfaceName, UDPFragmentDefault: true, }, }) if err != nil { return nil, err } - var serverSpecifications []resolvedServerSpecification - if len(linkDNSEx) > 0 { - for _, entry := range linkDNSEx { - serverSpecification, loaded := buildResolvedServerSpecification(defaultInterface.Name, entry.Address, entry.Port, entry.Name) - if !loaded { - continue - } - serverSpecifications = append(serverSpecifications, serverSpecification) - } - } else { - for _, entry := range linkDNS { - serverSpecification, loaded := buildResolvedServerSpecification(defaultInterface.Name, entry.Address, 0, "") - if !loaded { - continue - } - serverSpecifications = append(serverSpecifications, serverSpecification) - } - } - if len(serverSpecifications) == 0 { - return nil, E.New("no valid DNS servers on link") - } - serverSet := &resolvedServerSet{ - servers: make([]resolvedServer, 0, len(serverSpecifications)), - signature: common.Map(serverSpecifications, func(it resolvedServerSpecification) string { - return M.SocksaddrFrom(it.address, it.port).String() - }), - } - for _, serverSpecification := range serverSpecifications { - server, createErr := t.createResolvedServer(serverDialer, dnsOverTLSMode, serverSpecification) + for _, serverSpecification := range scopeSpecification.servers { + server, createErr := t.createResolvedServer(serverDialer, scopeSpecification.dnsOverTLSMode, serverSpecification) if createErr != nil { - _ = serverSet.Close() + _ = scope.Close() return nil, createErr } - serverSet.servers = append(serverSet.servers, server) + scope.servers = append(scope.servers, server) } - return serverSet, nil + return scope, nil } func (t *DBusResolvedResolver) createResolvedServer(serverDialer N.Dialer, dnsOverTLSMode string, serverSpecification resolvedServerSpecification) (resolvedServer, error) { @@ -501,19 +725,8 @@ func (t *DBusResolvedResolver) createResolvedTransport(serverDialer N.Dialer, se return serverTransport, nil } -func (s *resolvedServerSet) Close() error { - var errors []error - for _, server := range s.servers { - errors = append(errors, server.primaryTransport.Close()) - if server.fallbackTransport != nil { - errors = append(errors, server.fallbackTransport.Close()) - } - } - return E.Errors(errors...) -} - -func buildResolvedServerSpecification(interfaceName string, rawAddress []byte, port uint16, serverName string) (resolvedServerSpecification, bool) { - address, loaded := netip.AddrFromSlice(rawAddress) +func buildResolvedServerSpecification(interfaceName string, server resolvedManagerServer) (resolvedServerSpecification, bool) { + address, loaded := netip.AddrFromSlice(server.address) if !loaded { return resolvedServerSpecification{}, false } @@ -522,8 +735,8 @@ func buildResolvedServerSpecification(interfaceName string, rawAddress []byte, p } return resolvedServerSpecification{ address: address, - port: port, - serverName: serverName, + port: server.port, + serverName: server.serverName, }, true } @@ -537,57 +750,55 @@ func resolvedServerPort(port uint16, useTLS bool) uint16 { return 53 } -func loadResolvedLinkDNS(linkObject dbus.BusObject) ([]resolved.LinkDNS, error) { - dnsProperty, err := linkObject.GetProperty("org.freedesktop.resolve1.Link.DNS") +func loadResolvedProperties(ctx context.Context, object dbus.BusObject, interfaceName string) (map[string]dbus.Variant, error) { + var properties map[string]dbus.Variant + err := object.CallWithContext(ctx, "org.freedesktop.DBus.Properties.GetAll", 0, interfaceName).Store(&properties) if err != nil { - if isResolvedUnknownPropertyError(err) { - return nil, nil - } - return nil, err + return nil, E.Cause(err, "load ", interfaceName, " properties") } - var linkDNS []resolved.LinkDNS - err = dnsProperty.Store(&linkDNS) - if err != nil { - return nil, err - } - return linkDNS, nil + return properties, nil } -func loadResolvedLinkDNSEx(linkObject dbus.BusObject) ([]resolved.LinkDNSEx, error) { - dnsProperty, err := linkObject.GetProperty("org.freedesktop.resolve1.Link.DNSEx") - if err != nil { - if isResolvedUnknownPropertyError(err) { - return nil, nil - } - return nil, err +func storeResolvedProperty(properties map[string]dbus.Variant, name string, value any) error { + property, loaded := properties[name] + if !loaded { + return nil } - var linkDNSEx []resolved.LinkDNSEx - err = dnsProperty.Store(&linkDNSEx) + err := property.Store(value) + if err != nil { + return E.Cause(err, "parse resolved property ", name) + } + return nil +} + +func loadResolvedManagerServers(properties map[string]dbus.Variant, name string) ([]resolvedManagerServer, error) { + _, loaded := properties[name+"Ex"] + if loaded { + var serversEx []resolvedManagerDNSEx + err := storeResolvedProperty(properties, name+"Ex", &serversEx) + if err != nil { + return nil, err + } + return common.Map(serversEx, func(it resolvedManagerDNSEx) resolvedManagerServer { + return resolvedManagerServer{ + interfaceIndex: it.InterfaceIndex, + address: it.Address, + port: it.Port, + serverName: it.Name, + } + }), nil + } + var servers []resolvedManagerDNS + err := storeResolvedProperty(properties, name, &servers) if err != nil { return nil, err } - return linkDNSEx, nil -} - -func loadResolvedLinkDNSOverTLS(linkObject dbus.BusObject) (string, error) { - dnsOverTLSProperty, err := linkObject.GetProperty("org.freedesktop.resolve1.Link.DNSOverTLS") - if err != nil { - if isResolvedUnknownPropertyError(err) { - return "", nil + return common.Map(servers, func(it resolvedManagerDNS) resolvedManagerServer { + return resolvedManagerServer{ + interfaceIndex: it.InterfaceIndex, + address: it.Address, } - return "", err - } - var dnsOverTLSMode string - err = dnsOverTLSProperty.Store(&dnsOverTLSMode) - if err != nil { - return "", err - } - return dnsOverTLSMode, nil -} - -func isResolvedUnknownPropertyError(err error) bool { - var dbusError dbus.Error - return errors.As(err, &dbusError) && dbusError.Name == "org.freedesktop.DBus.Error.UnknownProperty" + }), nil } func shouldUpdateResolvedServerSet(signal *dbus.Signal) bool { From 021e0877a3a56de022a13ee3d2821319b2998417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 26 Sep 2026 20:10:32 +0800 Subject: [PATCH 39/66] Cleanup documentations --- docs/changelog.md | 96 +++++++------------ docs/clients/desktop/features.md | 2 +- docs/configuration/dns/index.md | 2 +- docs/configuration/dns/rule.md | 12 +-- docs/configuration/dns/rule.zh.md | 6 +- docs/configuration/dns/rule_action.md | 12 +-- docs/configuration/dns/rule_action.zh.md | 11 +-- docs/configuration/dns/server/mdns.md | 2 +- docs/configuration/dns/server/mdns.zh.md | 2 +- docs/configuration/dns/server/openconnect.md | 4 +- .../dns/server/openconnect.zh.md | 4 +- docs/configuration/dns/server/openvpn.md | 6 +- docs/configuration/dns/server/openvpn.zh.md | 6 +- docs/configuration/dns/server/tailscale.md | 2 - docs/configuration/dns/server/tailscale.zh.md | 2 - docs/configuration/endpoint/openconnect.md | 42 +++----- docs/configuration/endpoint/openconnect.zh.md | 42 +++----- docs/configuration/endpoint/openvpn-client.md | 64 +++---------- .../endpoint/openvpn-client.zh.md | 47 ++------- docs/configuration/endpoint/openvpn-server.md | 35 ++----- .../endpoint/openvpn-server.zh.md | 31 +++--- docs/configuration/endpoint/tailscale.md | 6 +- docs/configuration/endpoint/tailscale.zh.md | 6 +- docs/configuration/inbound/hysteria2.md | 6 +- docs/configuration/inbound/hysteria2.zh.md | 6 +- docs/configuration/inbound/snell.md | 2 +- docs/configuration/inbound/snell.zh.md | 2 +- docs/configuration/inbound/tun.md | 32 +++---- docs/configuration/inbound/tun.zh.md | 22 ++--- docs/configuration/outbound/bridge.md | 2 - docs/configuration/outbound/bridge.zh.md | 2 - docs/configuration/outbound/hysteria2.md | 8 +- docs/configuration/outbound/hysteria2.zh.md | 8 +- docs/configuration/rule-set/headless-rule.md | 6 +- .../rule-set/headless-rule.zh.md | 3 +- docs/configuration/rule-set/index.md | 4 +- docs/configuration/rule-set/index.zh.md | 3 +- docs/configuration/schema.md | 1 - docs/configuration/schema.zh.md | 1 - docs/configuration/service/api.md | 10 +- docs/configuration/service/api.zh.md | 8 +- docs/configuration/service/hysteria-realm.md | 4 +- .../service/hysteria-realm.zh.md | 4 +- docs/configuration/service/usbip-client.md | 6 -- docs/configuration/service/usbip-client.zh.md | 4 - docs/configuration/service/usbip-server.md | 6 -- docs/configuration/service/usbip-server.zh.md | 4 - .../shared/certificate-provider/acme.zh.md | 2 - docs/configuration/shared/dial.md | 2 +- docs/configuration/shared/dial.zh.md | 2 +- docs/configuration/shared/http-client.md | 3 +- docs/configuration/shared/http-client.zh.md | 3 +- docs/configuration/shared/pre-match.md | 3 +- docs/configuration/shared/pre-match.zh.md | 2 +- docs/configuration/shared/tls.md | 41 +------- docs/configuration/shared/tls.zh.md | 39 +------- docs/deprecated.md | 3 +- docs/deprecated.zh.md | 3 +- docs/installation/build-from-source.md | 6 +- docs/installation/build-from-source.zh.md | 6 +- docs/migration.md | 35 ++----- docs/migration.zh.md | 26 +---- 62 files changed, 211 insertions(+), 561 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 9c7de2cf..b58fc55b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -185,14 +185,11 @@ supplied at runtime through the API service by the graphical clients or the **10**: The new [Hysteria Realm service](/configuration/service/hysteria-realm/) -is a rendezvous service for Hysteria2 NAT traversal. A Hysteria2 server -behind NAT registers its STUN-discovered public addresses on a stable -realm endpoint via the new -[`realm`](/configuration/inbound/hysteria2/#realm) inbound field; -clients query the realm via the new -[`realm`](/configuration/outbound/hysteria2/#realm) outbound field to -learn the server's current addresses and perform UDP hole-punching to -establish a direct QUIC connection. +is a rendezvous service for Hysteria2 NAT traversal. Hysteria2 servers behind +NAT register on the realm via the new +[`realm`](/configuration/inbound/hysteria2/#realm) inbound field, and clients +connect to them through the realm via the new +[`realm`](/configuration/outbound/hysteria2/#realm) outbound field. [`realm.ip_version`](/configuration/outbound/hysteria2/#realmip_version) restricts realm connections to a single IP version, and [`realm.port_mapping`](/configuration/outbound/hysteria2/#realmport_mapping) @@ -247,8 +244,7 @@ See [Migration](/migration/#migrate-address-filter-fields-to-response-matching). referenced rule-sets, now take effect on every DNS rule evaluation, including matches from internal domain resolutions that do not target a specific DNS server (for example a `resolve` route rule action without -`server` set). In earlier versions they were silently ignored in that -path. Combining these fields with any of the legacy DNS fields deprecated +`server` set). Combining these fields with any of the legacy DNS fields deprecated in **12** in the same DNS configuration is no longer supported and is rejected at startup. See [Migration](/migration/#ip_version-and-query_type-behavior-changes-in-dns-rules). @@ -278,8 +274,7 @@ file option. A per-query field is also available on DNS rule actions and the `resolve` route rule action. -This deprecates the `independent_cache` DNS option (the DNS cache now -always keys by transport) and the `store_rdrc` cache file option +This deprecates the `independent_cache` DNS option and the `store_rdrc` cache file option (replaced by `store_dns`); both will be removed in sing-box 1.16.0. See [Migration](/migration/#migrate-independent-dns-cache). @@ -295,9 +290,8 @@ and a `timeout` field on The new [mDNS DNS server](/configuration/dns/server/mdns/) sends queries via multicast on the local network. The default -[local DNS server](/configuration/dns/server/local/) also routes queries for -`*.local.` and IPv4/IPv6 link-local reverse zones via mDNS on non-Apple -platforms (and via the system resolver on Apple), and the new +[local DNS server](/configuration/dns/server/local/) also resolves +`*.local.` and IPv4/IPv6 link-local reverse zones, and the new [`neighbor_domain`](/configuration/dns/server/local/#neighbor_domain) option answers single-label hosts from the [neighbor resolver](/configuration/shared/neighbor/). @@ -331,8 +325,7 @@ The default `hijack` mode now sets the platform's native interface DNS (`systemd-resolved` on Linux, per-interface DNS on Windows and Apple) and installs platform-level DNS hijacking (an `iproute2` rule on Linux, nftables DNAT when `auto_redirect` is enabled, WFP filters on Windows when -`strict_route` is enabled). Earlier versions did not touch the interface -DNS or the platform firewall. +`strict_route` is enabled). **20**: @@ -345,9 +338,7 @@ TUN and TProxy inbounds and the WireGuard endpoint. **21**: -For UDP connections, the first packet is available in pre-match, so protocol -sniffing runs on it directly and rule matching continues with the sniffed -metadata. +The `sniff` action now works in pre-match for UDP connections. See [Pre-match](/configuration/shared/pre-match/#sniff). @@ -364,8 +355,8 @@ previously inlined in each component. [`route.default_http_client`](/configuration/route/#default_http_client) selects the default client for remote rule-sets. The legacy fallback (use the default outbound when `http_clients` is empty altogether) is -preserved with a deprecation warning and will be removed in sing-box -1.16.0, together with the legacy `download_detour` remote rule-set option. +deprecated and will be removed in sing-box 1.16.0, together with the legacy +`download_detour` remote rule-set option. **23**: @@ -419,7 +410,7 @@ through Schannel via SSPI on Windows build 17763 or later. The new `apple` value for outbound TLS `engine` routes the TLS handshake through `Network.framework`, and the new `apple` [HTTP client `engine`](/configuration/shared/http-client/#engine) routes HTTP -requests through `NSURLSession`. The default remains `go`. +requests through `NSURLSession`. **28**: @@ -448,8 +439,7 @@ The rule-set [`tag`](/configuration/rule-set/#tag) field now accepts a list of tags to define multiple rule-sets sharing other options at once, with the `{tag}` placeholder in `path` or `url` replaced by each tag. The new [`initial_path`](/configuration/rule-set/#initial_path) option provides -initial content for remote rule-sets so startup is not blocked by the initial -download. +initial content for remote rule-sets. **31**: @@ -696,9 +686,7 @@ split-DNS resolvers and, when enabled, general pushed resolvers. The [OpenConnect Client](/configuration/endpoint/openconnect/) endpoint can now submit Fortinet host check results using the new [`fortinet_host_check`](/configuration/endpoint/openconnect/#fortinet_host_check) -option. This behavior is modeled after openfortivpn and is not an OpenConnect -feature. sing-box only submits explicitly configured values when requested by -the Fortinet server and does not collect system information automatically. +option. #### 1.14.0-alpha.48 @@ -1040,15 +1028,11 @@ The `control_http_client` field on **1**: The new [Hysteria Realm service](/configuration/service/hysteria-realm/) -is a rendezvous service for Hysteria2 NAT traversal. A Hysteria2 server -behind NAT registers its STUN-discovered public addresses on a stable -realm endpoint via the new -[`realm`](/configuration/inbound/hysteria2/#realm) inbound field; -clients query the realm via the new -[`realm`](/configuration/outbound/hysteria2/#realm) outbound field to -learn the server's current addresses and perform UDP hole-punching to -establish a direct QUIC connection. Once hole-punching succeeds, all -proxy traffic flows directly between client and server. +is a rendezvous service for Hysteria2 NAT traversal. Hysteria2 servers behind +NAT register on the realm via the new +[`realm`](/configuration/inbound/hysteria2/#realm) inbound field, and clients +connect to them through the realm via the new +[`realm`](/configuration/outbound/hysteria2/#realm) outbound field. #### 1.14.0-alpha.21 @@ -1068,19 +1052,14 @@ The default `hijack` mode now sets the platform's native interface DNS (`systemd-resolved` on Linux, per-interface DNS on Windows and Apple) and installs platform-level DNS hijacking (an `iproute2` rule on Linux, nftables DNAT when `auto_redirect` is enabled, WFP filters on Windows when -`strict_route` is enabled). Earlier versions did not touch the interface -DNS or the platform firewall. +`strict_route` is enabled). **2**: The new [mDNS DNS server](/configuration/dns/server/mdns/) sends queries via multicast on the local network. The default -[local DNS server](/configuration/dns/server/local/) also routes queries for -`*.local.` and IPv4/IPv6 link-local reverse zones via mDNS on non-Apple -platforms (and via the system resolver on Apple), so an explicit `mdns` -server is only needed to reference it from -[`preferred_by`](/configuration/dns/rule/#preferred_by) or to use it -standalone. +[local DNS server](/configuration/dns/server/local/) also resolves +`*.local.` and IPv4/IPv6 link-local reverse zones. **3**: @@ -1193,20 +1172,13 @@ HTTP/2 and QUIC parameters). Components that make outbound HTTP requests DERP `verify_client_url`, and the Tailscale `control_http_client` — now accept an inline HTTP client object or the tag of an `http_clients` entry, replacing the dial and TLS fields previously inlined in each -component. When the field is omitted, ACME, Cloudflare Origin CA, DERP -and Tailscale dial direct (their existing default). +component. -Remote rule-sets are the only HTTP-using component whose default for an -omitted `http_client` has historically resolved to the default outbound, -not to direct, and a typical configuration contains many of them. To -avoid repeating the same `http_client` block in every rule-set, [`route.default_http_client`](/configuration/route/#default_http_client) -selects a default rule-set client by tag and is the only field that -consults it. If `default_http_client` is empty and `http_clients` is -non-empty, the first entry is used automatically. The legacy fallback -(use the default outbound when `http_clients` is empty altogether) is -preserved with a deprecation warning and will be removed in sing-box -1.16.0, together with the legacy `download_detour` remote rule-set +selects the default HTTP client for remote rule-sets; if empty, the first +`http_clients` entry is used. The legacy fallback (use the default outbound +when `http_clients` is empty altogether) is deprecated and will be removed in +sing-box 1.16.0, together with the legacy `download_detour` remote rule-set option and the legacy dialer fields on Tailscale endpoints. **2**: @@ -1220,9 +1192,7 @@ places: the TLS handshake through `Network.framework` for direct TCP TLS client connections. -The default remains `go`. Both engines come with additional CGO and -framework memory overhead and platform restrictions documented on each -field. +Both engines have platform restrictions documented on each field. **3**: @@ -1280,8 +1250,7 @@ file option. A per-query field is also available on DNS rule actions and the `resolve` route rule action. -This deprecates the `independent_cache` DNS option (the DNS cache now -always keys by transport) and the `store_rdrc` cache file option +This deprecates the `independent_cache` DNS option and the `store_rdrc` cache file option (replaced by `store_dns`); both will be removed in sing-box 1.16.0. See [Migration](/migration/#migrate-independent-dns-cache). @@ -1320,8 +1289,7 @@ See [Migration](/migration/#migrate-address-filter-fields-to-response-matching). referenced rule-sets, now take effect on every DNS rule evaluation, including matches from internal domain resolutions that do not target a specific DNS server (for example a `resolve` route rule action without -`server` set). In earlier versions they were silently ignored in that -path. Combining these fields with any of the legacy DNS fields deprecated +`server` set). Combining these fields with any of the legacy DNS fields deprecated in **1** in the same DNS configuration is no longer supported and is rejected at startup. See [Migration](/migration/#ip_version-and-query_type-behavior-changes-in-dns-rules). diff --git a/docs/clients/desktop/features.md b/docs/clients/desktop/features.md index bb399a03..f9ecc580 100644 --- a/docs/clients/desktop/features.md +++ b/docs/clients/desktop/features.md @@ -2,7 +2,7 @@ #### Service -SFW runs sing-box as a system service, so no administrator elevation is required for daily use. +SFW runs sing-box as a system service. ### Chore diff --git a/docs/configuration/dns/index.md b/docs/configuration/dns/index.md index 19a183ac..9351604a 100644 --- a/docs/configuration/dns/index.md +++ b/docs/configuration/dns/index.md @@ -77,7 +77,7 @@ Conflict with `optimistic`. !!! failure "Deprecated in sing-box 1.14.0" - `independent_cache` is deprecated and will be removed in sing-box 1.14.0, check [Migration](/migration/#migrate-independent-dns-cache). + `independent_cache` is deprecated and will be removed in sing-box 1.16.0, check [Migration](/migration/#migrate-independent-dns-cache). Make each DNS server's cache independent for special purposes. If enabled, will slightly degrade performance. diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md index ab5ca0f3..7a82f188 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -262,10 +262,8 @@ Tags of [Inbound](/configuration/inbound/). This field now also applies when a DNS rule is matched from an internal domain resolution that does not target a specific DNS server, such as a [`resolve`](../../route/rule_action/#resolve) route rule action without a - `server` set. In earlier versions, only DNS queries received from a - client evaluated this field. See - [Migration](/migration/#ip_version-and-query_type-behavior-changes-in-dns-rules) - for the full list. + `server` set. See + [Migration](/migration/#ip_version-and-query_type-behavior-changes-in-dns-rules). Setting this field makes the DNS rule incompatible in the same DNS configuration with Legacy Address Filter Fields in DNS rules, the Legacy @@ -285,10 +283,8 @@ Not limited if empty. This field now also applies when a DNS rule is matched from an internal domain resolution that does not target a specific DNS server, such as a [`resolve`](../../route/rule_action/#resolve) route rule action without a - `server` set. In earlier versions, only DNS queries received from a - client evaluated this field. See - [Migration](/migration/#ip_version-and-query_type-behavior-changes-in-dns-rules) - for the full list. + `server` set. See + [Migration](/migration/#ip_version-and-query_type-behavior-changes-in-dns-rules). Setting this field makes the DNS rule incompatible in the same DNS configuration with Legacy Address Filter Fields in DNS rules, the Legacy diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index a72101f6..572f9d68 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -260,8 +260,7 @@ icon: material/alert-decagram !!! quote "sing-box 1.14.0 中的更改" 此字段现在也会在 DNS 规则被未指定具体 DNS 服务器的内部域名解析匹配时生效, - 例如未设置 `server` 的 [`resolve`](../../route/rule_action/#resolve) 路由规则动作。 - 此前只有来自客户端的 DNS 查询才会评估此字段。完整列表参阅 + 例如未设置 `server` 的 [`resolve`](../../route/rule_action/#resolve) 路由规则动作。参阅 [迁移指南](/zh/migration/#dns-规则中的-ip_version-和-query_type-行为更改)。 在 DNS 规则中设置此字段后,该 DNS 规则在同一 DNS 配置中不能与 @@ -279,8 +278,7 @@ icon: material/alert-decagram !!! quote "sing-box 1.14.0 中的更改" 此字段现在也会在 DNS 规则被未指定具体 DNS 服务器的内部域名解析匹配时生效, - 例如未设置 `server` 的 [`resolve`](../../route/rule_action/#resolve) 路由规则动作。 - 此前只有来自客户端的 DNS 查询才会评估此字段。完整列表参阅 + 例如未设置 `server` 的 [`resolve`](../../route/rule_action/#resolve) 路由规则动作。参阅 [迁移指南](/zh/migration/#dns-规则中的-ip_version-和-query_type-行为更改)。 在 DNS 规则中设置此字段后,该 DNS 规则在同一 DNS 配置中不能与 diff --git a/docs/configuration/dns/rule_action.md b/docs/configuration/dns/rule_action.md index 0476588d..28f829bb 100644 --- a/docs/configuration/dns/rule_action.md +++ b/docs/configuration/dns/rule_action.md @@ -55,7 +55,7 @@ immediately; the remaining queries are canceled. Rules without `race` still take effect strictly in listed order: while a preceding race rule is not yet judged, the action of any other matched rule is held until none of the race rules -matched. The result may therefore depend on server speed only among race rules. +matched. ### route @@ -92,8 +92,7 @@ By default, no query is sent in parallel with pending race rules: a matched `rou holds its query until none of the race rules matched. When `speculative` is enabled, the query is sent as soon as the rule matches, in parallel with -the pending race rules, and may be wasted: its response is still used only after none of the -race rules matched. +the pending race rules; its response is used only after none of the race rules matched. #### strategy @@ -170,8 +169,7 @@ Unlike `route`, it does **not** terminate rule evaluation. Only allowed on top-level DNS rules (not inside logical sub-rules). Rules that use [`match_response`](/configuration/dns/rule/#match_response) or Response Match Fields -require a preceding top-level rule with `evaluate` action. A rule's own `evaluate` action -does not satisfy this requirement, because matching happens before the action runs. +require a preceding top-level rule with `evaluate` action. #### server @@ -196,7 +194,7 @@ By default, no query is sent in parallel with pending race rules: a matched `eva holds its query, and rule matching stops there, until none of the race rules matched. When `speculative` is enabled, the query is sent as soon as the rule matches, in parallel with -the pending race rules, and may be wasted: rule matching continues without waiting for them. +the pending race rules, and rule matching continues without waiting for them. #### disable_cache @@ -248,8 +246,6 @@ Conflict with `client_subnet`. `respond` terminates rule evaluation and returns the evaluated response from a preceding [`evaluate`](/configuration/dns/rule_action/#evaluate) action. -This action does not send a new DNS query. - Only allowed after a preceding top-level `evaluate` rule. If the action is reached without an evaluated response at runtime, the request fails with an error instead of falling through to later rules. ### route-options diff --git a/docs/configuration/dns/rule_action.zh.md b/docs/configuration/dns/rule_action.zh.md index e01a6e2c..86d77ea0 100644 --- a/docs/configuration/dns/rule_action.zh.md +++ b/docs/configuration/dns/rule_action.zh.md @@ -48,7 +48,7 @@ icon: material/new-box 启用 `race` 的规则成为竞态规则,不再保持这一顺序:其引用的响应尚未到达时,规则匹配会越过它继续进行,因此竞态规则的匹配相互并行、也与后续规则并行。每条竞态规则在其引用的响应可用时被判定,首个匹配的竞态规则立即终止规则评估,其余查询将被取消。 -未启用 `race` 的规则仍严格按顺序生效:只要前面还有未判定的竞态规则,其他已匹配规则的动作就被扣住,直到所有竞态规则均未匹配。因此只有竞态规则之间的结果取决于服务器速度。 +未启用 `race` 的规则仍严格按顺序生效:只要前面还有未判定的竞态规则,其他已匹配规则的动作就被扣住,直到所有竞态规则均未匹配。 ### route @@ -83,7 +83,7 @@ icon: material/new-box 默认情况下,查询决不与未判定的竞态规则并行发出:已匹配的 `route` 动作扣住其查询,直到所有竞态规则均未匹配后才发送。 -启用 `speculative` 后,查询成为投机查询:在规则匹配时立即发出、与未判定的竞态规则并行,且可能被浪费;其响应仍仅在所有竞态规则均未匹配后才被使用。 +启用 `speculative` 后,查询在规则匹配时立即发出、与未判定的竞态规则并行;其响应仅在所有竞态规则均未匹配后才被使用。 #### strategy @@ -158,8 +158,7 @@ icon: material/new-box 仅允许在顶层 DNS 规则中使用(不可在逻辑子规则内部使用)。 使用 [`match_response`](/zh/configuration/dns/rule/#match_response) 或响应匹配字段的规则, -需要位于更早的顶层 `evaluate` 规则之后。规则自身的 `evaluate` 动作不能满足这个条件, -因为匹配发生在动作执行之前。 +需要位于更早的顶层 `evaluate` 规则之后。 #### server @@ -182,7 +181,7 @@ icon: material/new-box 默认情况下,查询决不与未判定的竞态规则并行发出:已匹配的 `evaluate` 动作扣住其查询,规则匹配在此处停止,直到所有竞态规则均未匹配。 -启用 `speculative` 后,查询成为投机查询:在规则匹配时立即发出、与未判定的竞态规则并行,且可能被浪费;规则匹配继续进行而不等待竞态规则。 +启用 `speculative` 后,查询在规则匹配时立即发出、与未判定的竞态规则并行,规则匹配继续进行而不等待竞态规则。 #### disable_cache @@ -234,8 +233,6 @@ icon: material/new-box `respond` 会终止规则评估,并直接返回前序 [`evaluate`](/zh/configuration/dns/rule_action/#evaluate) 动作保存的已评估的响应。 -此动作不会发起新的 DNS 查询。 - 只能用于前面已有顶层 `evaluate` 规则的场景。如果运行时命中该动作时没有已评估的响应,则请求会直接返回错误,而不是继续匹配后续规则。 ### route-options diff --git a/docs/configuration/dns/server/mdns.md b/docs/configuration/dns/server/mdns.md index d42e0715..4b2fedaa 100644 --- a/docs/configuration/dns/server/mdns.md +++ b/docs/configuration/dns/server/mdns.md @@ -27,7 +27,7 @@ icon: material/new-box !!! info "" - You usually do not need an explicit `mdns` server in addition to a [Local](./local/) server: the local server already routes queries for `*.local.` and IPv4/IPv6 link-local reverse zones via mDNS on non-Apple platforms and via the system resolver on Apple platforms. Add an explicit `mdns` server only when you want to reference it from [`preferred_by`](../rule/#preferred_by) or use it standalone. + `*.local.` and IPv4/IPv6 link-local reverse zones are also resolved by the [Local](./local/) server. ### Fields diff --git a/docs/configuration/dns/server/mdns.zh.md b/docs/configuration/dns/server/mdns.zh.md index 3af3763e..ac0297ac 100644 --- a/docs/configuration/dns/server/mdns.zh.md +++ b/docs/configuration/dns/server/mdns.zh.md @@ -27,7 +27,7 @@ icon: material/new-box !!! info "" - 通常不需要在 [Local](./local/) 服务器之外再添加显式的 `mdns` 服务器:本地服务器已经会在非 Apple 平台通过 mDNS、在 Apple 平台通过系统解析器来回答 `*.local.` 与 IPv4/IPv6 链路本地反向区域的查询。仅当需要从 [`preferred_by`](../rule/#preferred_by) 引用,或独立使用时,才需要显式添加 `mdns` 服务器。 + [Local](./local/) 服务器也会解析 `*.local.` 与 IPv4/IPv6 链路本地反向区域。 ### 字段 diff --git a/docs/configuration/dns/server/openconnect.md b/docs/configuration/dns/server/openconnect.md index 0ba053be..4252616e 100644 --- a/docs/configuration/dns/server/openconnect.md +++ b/docs/configuration/dns/server/openconnect.md @@ -33,9 +33,7 @@ icon: material/new-box The tag of the [OpenConnect Endpoint](/configuration/endpoint/openconnect). -DNS queries are sent to the resolvers pushed by the VPN server through the OpenConnect endpoint. Pushed split-DNS rules use their dedicated resolvers, while pushed split-DNS and search-domain suffixes use the general pushed resolvers. The most specific matching suffix takes precedence. - -Pushed DNS settings are not installed into the operating system. +DNS queries are sent to the resolvers pushed by the VPN server through the OpenConnect endpoint. #### accept_default_resolvers diff --git a/docs/configuration/dns/server/openconnect.zh.md b/docs/configuration/dns/server/openconnect.zh.md index af812851..175581db 100644 --- a/docs/configuration/dns/server/openconnect.zh.md +++ b/docs/configuration/dns/server/openconnect.zh.md @@ -33,9 +33,7 @@ icon: material/new-box [OpenConnect 端点](/zh/configuration/endpoint/openconnect) 的标签。 -DNS 查询会通过 OpenConnect 端点发送到 VPN 服务器推送的解析器。推送的分流 DNS 规则使用各自的专用解析器,推送的分流 DNS 和搜索域后缀则使用通用推送解析器。匹配时优先使用最具体的后缀。 - -推送的 DNS 设置不会安装到操作系统中。 +DNS 查询会通过 OpenConnect 端点发送到 VPN 服务器推送的解析器。 #### accept_default_resolvers diff --git a/docs/configuration/dns/server/openvpn.md b/docs/configuration/dns/server/openvpn.md index d11a4573..71f25fc0 100644 --- a/docs/configuration/dns/server/openvpn.md +++ b/docs/configuration/dns/server/openvpn.md @@ -33,11 +33,9 @@ icon: material/new-box The tag of the [OpenVPN Client Endpoint](/configuration/endpoint/openvpn-client). -DNS queries are sent through the endpoint to resolvers pushed by the OpenVPN server. Modern OpenVPN `dns server` options support plain DNS, DNS over TLS, DNS over HTTPS, custom ports, SNI, and `resolve-domains`. Only the server group with the lowest priority number is active. Legacy `dhcp-option DNS`/`DNS6` and `DOMAIN-ROUTE` are used when no modern server group is present. +DNS queries are sent through the endpoint to resolvers pushed by the OpenVPN server. -A modern server group overrides legacy DHCP DNS resolver and domain options. A standalone modern `dns search-domains` option does not remove legacy resolvers. Required DNSSEC validation (`dnssec yes`) is rejected because this transport does not provide DNSSEC validation. - -Pushed DNS settings are not installed into the operating system. +Both modern `dns` options and legacy `dhcp-option DNS`/`DNS6` and `DOMAIN-ROUTE` options are supported. `dnssec yes` is not supported. #### accept_default_resolvers diff --git a/docs/configuration/dns/server/openvpn.zh.md b/docs/configuration/dns/server/openvpn.zh.md index 29d1d668..b537bf04 100644 --- a/docs/configuration/dns/server/openvpn.zh.md +++ b/docs/configuration/dns/server/openvpn.zh.md @@ -33,11 +33,9 @@ icon: material/new-box [OpenVPN 客户端端点](/zh/configuration/endpoint/openvpn-client) 的标签。 -DNS 查询会通过该端点发送到 OpenVPN 服务器推送的解析器。现代 OpenVPN `dns server` 选项支持普通 DNS、DNS over TLS、DNS over HTTPS、自定义端口、SNI 和 `resolve-domains`。只有优先级数字最低的服务器组会生效。没有现代服务器组时,使用传统的 `dhcp-option DNS`/`DNS6` 和 `DOMAIN-ROUTE`。 +DNS 查询会通过该端点发送到 OpenVPN 服务器推送的解析器。 -现代服务器组会覆盖传统 DHCP DNS 解析器及相关域选项。只有现代 `dns search-domains` 而没有现代服务器组时,不会移除传统解析器。由于此传输不提供 DNSSEC 验证,需要强制验证的 `dnssec yes` 会被拒绝。 - -推送的 DNS 设置不会安装到操作系统中。 +支持现代 `dns` 选项,以及传统的 `dhcp-option DNS`/`DNS6` 和 `DOMAIN-ROUTE` 选项。不支持 `dnssec yes`。 #### accept_default_resolvers diff --git a/docs/configuration/dns/server/tailscale.md b/docs/configuration/dns/server/tailscale.md index d28a11c6..35d22f83 100644 --- a/docs/configuration/dns/server/tailscale.md +++ b/docs/configuration/dns/server/tailscale.md @@ -49,8 +49,6 @@ if not enabled, `NXDOMAIN` will be returned for non-Tailscale domain queries. When enabled, single-label queries (e.g. `my-device`) are retried against each Tailscale search domain until one resolves. -Default resolvers are not consulted for single-label queries regardless of `accept_default_resolvers`. - ### Examples === "MagicDNS only" diff --git a/docs/configuration/dns/server/tailscale.zh.md b/docs/configuration/dns/server/tailscale.zh.md index 40fae69c..d1b33c93 100644 --- a/docs/configuration/dns/server/tailscale.zh.md +++ b/docs/configuration/dns/server/tailscale.zh.md @@ -49,8 +49,6 @@ icon: material/new-box 启用后,单标签查询(例如 `my-device`)将依次附加 Tailscale 搜索域进行重试,直到其中一个解析成功。 -对于单标签查询,无论 `accept_default_resolvers` 是否启用,都不会使用默认 DNS 解析器。 - ### 示例 === "仅 MagicDNS" diff --git a/docs/configuration/endpoint/openconnect.md b/docs/configuration/endpoint/openconnect.md index 8afb65ba..ef8e3c34 100644 --- a/docs/configuration/endpoint/openconnect.md +++ b/docs/configuration/endpoint/openconnect.md @@ -198,7 +198,7 @@ For `totp` and `hotp`, this can be a Base32 secret, a `base32:`-prefixed secret, For `stoken`, this is the encoded RSA SecurID CTF token content. -For `oidc`, this is the access token value. It is sent only after the VPN server requests HTTP Bearer authentication. +For `oidc`, this is the access token value. Conflict with `token.secret_path`. @@ -244,7 +244,7 @@ The default is flavor-specific. AnyConnect, Network Connect, Pulse, and F5 use ` Client version reported separately from `user_agent` when supported by the selected flavor. -`v9.21` is used by default. Currently used by AnyConnect XML authentication. +`v9.21` is used by default. ### local_hostname @@ -254,7 +254,7 @@ The system hostname is used by default, or `localhost` if it is unavailable. ### mobile -AnyConnect mobile client identity. When configured, all three fields are required and are reported during XML authentication and tunnel establishment. +AnyConnect mobile client identity. When configured, all three fields are required. ### mobile.platform_version @@ -348,11 +348,9 @@ Conflict with `tncc.certificates.certificate`. ### fortinet_host_check -Fortinet hostcheck result override. +Fortinet hostcheck result override, submitted when requested by the server. -Hostcheck is disabled by default. It is enabled only when `fortinet_host_check.hostcheck` is non-empty. No operating system, security product, or network interface information is collected automatically. - -When enabled and a successful Fortinet login response requests hostcheck, both configured values are submitted to the server before the VPN session is used. The values are sent unchanged as `application/x-www-form-urlencoded` fields. +Disabled if `fortinet_host_check.hostcheck` is empty. Some Fortinet servers only request hostcheck from recognized FortiClient user agents. Configure `user_agent` when required by the server policy. @@ -362,13 +360,11 @@ Fortinet hostcheck result string. The conventional format is `,`, for example `0100,10.0.19042`. `security-status` contains four `0` or `1` characters representing, in order, third-party firewall, third-party antivirus, FortiClient firewall, and FortiClient antivirus. -An empty value disables Fortinet hostcheck, even if `fortinet_host_check.check_virtual_desktop` is configured. - ### fortinet_host_check.check_virtual_desktop Fortinet virtual desktop check result string. -FortiClient conventionally sends colon-separated MAC addresses joined by `|`, for example `74:78:27:4d:81:93|84:1b:77:3a:95:84`. An empty value is submitted as an empty field when hostcheck is enabled. +FortiClient conventionally sends colon-separated MAC addresses joined by `|`, for example `74:78:27:4d:81:93|84:1b:77:3a:95:84`. ### no_udp @@ -386,8 +382,6 @@ Disable AnyConnect compression negotiation. By default, stateless `oc-lz4` and `lzs` compression is negotiated for CSTP and DTLS when supported by the server. -Compression can weaken traffic confidentiality when an attacker can influence plaintext sent through the VPN tunnel. - Conflict with `compression_mode` set to `all`. ### compression_mode @@ -397,9 +391,7 @@ AnyConnect compression mode, one of: - `stateless`: Advertise stateless `oc-lz4` and `lzs` compression. - `all`: Additionally advertise stateful `deflate` compression for CSTP. -`stateless` is used by default. DTLS always uses stateless compression, including when `all` is selected. - -Stateful compression has additional traffic confidentiality risks and should only be enabled when required by the VPN server. +`stateless` is used by default. ### ipv6_disabled @@ -417,19 +409,15 @@ Disable AnyConnect XML POST authentication and start authentication with the leg Disable external browser authentication such as SSO and SAML for AnyConnect, GlobalProtect, and Fortinet. -When enabled, external authentication is not advertised for AnyConnect or GlobalProtect, and any unexpected external authentication request, including Fortinet SAML, is rejected. - ### password_authentication_disabled Abort AnyConnect authentication if the server returns a non-success authentication form, matching OpenConnect `--no-passwd` behavior. -This does not affect the other flavors or a session supplied by `cookie`. - ### tcp_keep_alive_enabled Enable TCP keep alive for direct VPN server connections. -Disabled by default to match OpenConnect. Setting `tcp_keep_alive` or `tcp_keep_alive_interval` also enables it without requiring this field. When enabled without either duration, the operating system TCP keep alive timing is retained. +Setting `tcp_keep_alive` or `tcp_keep_alive_interval` also enables it. When enabled without either duration, the operating system defaults are used. Conflict with `disable_tcp_keep_alive`. @@ -437,13 +425,11 @@ Conflict with `disable_tcp_keep_alive`. Require forward-secret TLS cipher suites for TLS 1.2 and earlier. -Disabled by default for compatibility with VPN servers that require RSA key exchange. This does not enable deprecated cipher suites; see `allow_insecure_crypto` for legacy crypto support. - ### mtu Preferred tunnel MTU. -The negotiated MTU is limited to this value for all flavors. For AnyConnect, this value is also sent to the server. GlobalProtect, F5, and Fortinet remove their protocol overhead before using it as the tunnel MTU. +The negotiated MTU is limited to this value. Non-zero values below `576` are treated as `576`. The maximum value is `65535`. @@ -465,7 +451,7 @@ Positive values below `2s` are treated as `2s`. The value must not be negative. ### reconnect_timeout -Maximum accumulated backoff time after failed reconnect attempts. The first reconnect attempt starts immediately, and this timeout does not cancel an attempt already in progress. +Maximum accumulated backoff time after failed reconnect attempts. `300s` is used by default. @@ -483,13 +469,13 @@ The value must not be negative. Inbound and outbound packet queue length between the VPN transport and the tunnel interface. -`32` is used by default. A full queue applies backpressure until its consumer makes room; queued packets are not discarded. +`32` is used by default. ### allow_insecure_crypto Enable weak TLS and DTLS cipher suites and TLS 1.0 compatibility required by legacy VPN servers. -Disabled by default; TLS versions below 1.2 are otherwise rejected. This option does not disable server certificate verification. +Disabled by default; TLS versions below 1.2 are otherwise rejected. ### tls @@ -499,7 +485,7 @@ OpenConnect TLS configuration. Disable verification of the VPN server certificate and hostname. -Disabled by default. Enabling this permits an active attacker to impersonate the VPN server. Prefer `tls.certificate_authority` or `tls.peer_fingerprint` when possible. +Disabled by default. Prefer `tls.certificate_authority` or `tls.peer_fingerprint` when possible. ### tls.server_name @@ -648,4 +634,4 @@ Use `Tools` > `Endpoints` in the sing-box dashboard or any sing-box graphical cl ## DNS -Pushed DNS settings are not installed into the operating system. Configure an [OpenConnect DNS server](/configuration/dns/server/openconnect/) to use them through sing-box. +Configure an [OpenConnect DNS server](/configuration/dns/server/openconnect/) to use the pushed DNS settings. diff --git a/docs/configuration/endpoint/openconnect.zh.md b/docs/configuration/endpoint/openconnect.zh.md index 904a4041..eab4f2e3 100644 --- a/docs/configuration/endpoint/openconnect.zh.md +++ b/docs/configuration/endpoint/openconnect.zh.md @@ -198,7 +198,7 @@ Token 模式,可选值为: 对于 `stoken`,这是编码后的 RSA SecurID CTF token 内容。 -对于 `oidc`,这是 access token 值。仅在 VPN 服务器请求 HTTP Bearer 认证后发送。 +对于 `oidc`,这是 access token 值。 与 `token.secret_path` 冲突。 @@ -244,7 +244,7 @@ Token 模式,可选值为: 所选 flavor 支持时,与 `user_agent` 分开报告的客户端版本。 -默认使用 `v9.21`。当前用于 AnyConnect XML 认证。 +默认使用 `v9.21`。 ### local_hostname @@ -254,7 +254,7 @@ Token 模式,可选值为: ### mobile -AnyConnect 移动客户端身份。配置时三个字段均为必填,并会在 XML 认证和隧道建立阶段报告。 +AnyConnect 移动客户端身份。配置时三个字段均为必填。 ### mobile.platform_version @@ -348,11 +348,9 @@ PEM 格式的 TNCC 机器证书路径。 ### fortinet_host_check -Fortinet hostcheck 结果覆盖选项。 +Fortinet hostcheck 结果覆盖选项,在服务器要求时提交。 -默认禁用 hostcheck。仅当 `fortinet_host_check.hostcheck` 非空时启用。不会自动收集操作系统、安全产品或网络接口信息。 - -启用后,如果成功的 Fortinet 登录响应要求 hostcheck,将在使用 VPN 会话前向服务器提交两个配置值。这些值不经修改,作为 `application/x-www-form-urlencoded` 字段发送。 +`fortinet_host_check.hostcheck` 为空时禁用。 部分 Fortinet 服务器只会要求可识别的 FortiClient User-Agent 执行 hostcheck。服务器策略有要求时请配置 `user_agent`。 @@ -362,13 +360,11 @@ Fortinet hostcheck 结果字符串。 通常格式为 `,`,例如 `0100,10.0.19042`。`security-status` 包含四个 `0` 或 `1` 字符,依次表示第三方防火墙、第三方杀毒软件、FortiClient 防火墙和 FortiClient 杀毒软件。 -空值会禁用 Fortinet hostcheck,即使配置了 `fortinet_host_check.check_virtual_desktop`。 - ### fortinet_host_check.check_virtual_desktop Fortinet virtual desktop 检查结果字符串。 -FortiClient 通常发送以冒号分隔的 MAC 地址,多个地址使用 `|` 连接,例如 `74:78:27:4d:81:93|84:1b:77:3a:95:84`。启用 hostcheck 时,空值会作为空字段提交。 +FortiClient 通常发送以冒号分隔的 MAC 地址,多个地址使用 `|` 连接,例如 `74:78:27:4d:81:93|84:1b:77:3a:95:84`。 ### no_udp @@ -386,8 +382,6 @@ FortiClient 通常发送以冒号分隔的 MAC 地址,多个地址使用 `|` 默认情况下,当服务器支持时,CSTP 和 DTLS 会协商无状态 `oc-lz4` 和 `lzs` 压缩。 -当攻击者能够影响通过 VPN 隧道发送的明文时,压缩可能削弱流量机密性。 - 与设置为 `all` 的 `compression_mode` 冲突。 ### compression_mode @@ -397,9 +391,7 @@ AnyConnect 压缩模式,可选值为: - `stateless`:声明支持无状态 `oc-lz4` 和 `lzs` 压缩。 - `all`:额外声明支持 CSTP 有状态 `deflate` 压缩。 -默认使用 `stateless`。即使选择 `all`,DTLS 也始终使用无状态压缩。 - -有状态压缩存在额外的流量机密性风险,仅应在 VPN 服务器需要时启用。 +默认使用 `stateless`。 ### ipv6_disabled @@ -417,19 +409,15 @@ AnyConnect 压缩模式,可选值为: 禁用 AnyConnect、GlobalProtect 和 Fortinet 的 SSO、SAML 等外部浏览器认证。 -启用时不会为 AnyConnect 或 GlobalProtect 向服务器声明外部认证支持,并会拒绝任何意外收到的外部认证请求,包括 Fortinet SAML。 - ### password_authentication_disabled 如果服务器返回非成功的认证表单,则中止 AnyConnect 认证,与 OpenConnect `--no-passwd` 行为一致。 -此选项不影响其他 flavor,也不影响由 `cookie` 提供的会话。 - ### tcp_keep_alive_enabled 为直接 VPN 服务器连接启用 TCP keep alive。 -默认禁用以匹配 OpenConnect。设置 `tcp_keep_alive` 或 `tcp_keep_alive_interval` 也会启用,无需同时设置此字段。启用但未设置这两个时间值时,保留操作系统的 TCP keep alive 时间设置。 +设置 `tcp_keep_alive` 或 `tcp_keep_alive_interval` 也会启用。启用但未设置这两个时间值时,使用操作系统默认值。 与 `disable_tcp_keep_alive` 冲突。 @@ -437,13 +425,11 @@ AnyConnect 压缩模式,可选值为: 要求 TLS 1.2 及更早版本使用具有前向保密性的 TLS 密码套件。 -默认禁用,以兼容需要 RSA 密钥交换的 VPN 服务器。此选项不会启用已弃用的密码套件;旧版加密支持参阅 `allow_insecure_crypto`。 - ### mtu 首选隧道 MTU。 -所有 flavor 协商的 MTU 都不会超过此值。对于 AnyConnect,此值还会发送给服务器。GlobalProtect、F5 和 Fortinet 会先扣除各自的协议开销,再将结果作为隧道 MTU。 +协商的 MTU 不超过此值。 非零值小于 `576` 时按 `576` 处理。最大值为 `65535`。 @@ -465,7 +451,7 @@ AnyConnect 压缩模式,可选值为: ### reconnect_timeout -重连尝试失败后允许累计使用的最大退避时间。断线后的第一次重连会立即开始,且此超时不会取消已经进行中的尝试。 +重连尝试失败后允许累计使用的最大退避时间。 默认使用 `300s`。 @@ -483,13 +469,13 @@ AnyConnect 压缩模式,可选值为: VPN transport 与隧道接口之间的入站和出站数据包队列长度。 -默认使用 `32`。队列已满时会施加反压并等待消费者腾出空间,不会丢弃已排队的数据包。 +默认使用 `32`。 ### allow_insecure_crypto 启用旧版 VPN 服务器所需的弱 TLS 和 DTLS 密码套件及 TLS 1.0 兼容性。 -默认禁用;未启用时会拒绝低于 TLS 1.2 的版本。此选项不会禁用服务器证书验证。 +默认禁用;未启用时会拒绝低于 TLS 1.2 的版本。 ### tls @@ -499,7 +485,7 @@ OpenConnect TLS 配置。 禁用 VPN 服务器证书和主机名验证。 -默认禁用。启用后,主动攻击者可以冒充 VPN 服务器。应尽可能使用 `tls.certificate_authority` 或 `tls.peer_fingerprint`。 +默认禁用。应尽可能使用 `tls.certificate_authority` 或 `tls.peer_fingerprint`。 ### tls.server_name @@ -648,4 +634,4 @@ MCA 证书和私钥必须同时设置或同时为空。 ## DNS -推送的 DNS 设置不会安装到操作系统中。配置 [OpenConnect DNS 服务器](/zh/configuration/dns/server/openconnect/) 以通过 sing-box 使用这些设置。 +配置 [OpenConnect DNS 服务器](/zh/configuration/dns/server/openconnect/) 以使用推送的 DNS 设置。 diff --git a/docs/configuration/endpoint/openvpn-client.md b/docs/configuration/endpoint/openvpn-client.md index d35a35f7..7501beba 100644 --- a/docs/configuration/endpoint/openvpn-client.md +++ b/docs/configuration/endpoint/openvpn-client.md @@ -120,9 +120,7 @@ OpenVPN session mode, one of `tls` or `static_key`. `tls` is used by default. `static_key` is a deprecated OpenVPN mode without a TLS control channel or -forward secrecy. It is retained as an explicit compatibility option for -immutable enterprise VPN servers. It does not use `tls`, username/password -authentication, pull options, or TLS renegotiation options. +forward secrecy. ### server @@ -264,7 +262,7 @@ OpenVPN control channel TLS configuration. Expected server certificate name. -Certificate name verification is disabled if empty. The certificate chain or fingerprint and server certificate usage are still verified. +Certificate name verification is disabled if empty. ### tls.server_name_type @@ -322,14 +320,10 @@ Allowed SHA-256 fingerprints of the server leaf certificate. Each fingerprint must be 64 lowercase hexadecimal characters without separators. -When a trusted CA is also configured, both the certificate chain and fingerprint are verified. Without a trusted CA, the fingerprint, certificate validity period, configured name, and certificate usage are verified, but the certificate chain is not. - ### tls.crl_path Path to a PEM or DER certificate revocation list used to reject revoked server certificates. -The CRL signature and validity period are verified against the trusted certificate chain. - Disabled by default. ### tls.remote_certificate_ku @@ -371,7 +365,7 @@ keys for compatibility with immutable peers. Use it only when the peer cannot be upgraded. `legacy` accepts SHA-1 but rejects MD5 signatures; `preferred` requires stronger signatures and keys. -When `suiteb` is selected and `tls.cipher` is empty, the TLS 1.2 cipher list defaults to the Suite B ECDHE-ECDSA AES-GCM suites. Explicit `tls.cipher` and `tls.groups` values are not restricted by the profile. +When `suiteb` is selected and `tls.cipher` is empty, the TLS 1.2 cipher list defaults to the Suite B ECDHE-ECDSA AES-GCM suites. ### tls.ns_certificate_type @@ -397,7 +391,7 @@ The value cannot be lower than `tls.version_min`. Colon-separated OpenSSL cipher suite names allowed for TLS 1.2 and earlier. -The default TLS cipher suites are used when empty. TLS 1.3 cipher suites are not controlled by this field. +The default TLS cipher suites are used when empty. ### tls.groups @@ -441,13 +435,11 @@ Only available when `tls.control_wrap.type` is `tls_auth`. The key is used bidir Data-channel cipher used in `static_key` mode. -The upstream static-key default `BF-CBC` is used when empty. `BF-CBC` is a -legacy cipher with a 64-bit block size; configure the cipher required by the -server explicitly whenever possible. Static-key ciphers include `BF-CBC`, +The upstream static-key default `BF-CBC` is used when empty. Static-key ciphers include `BF-CBC`, `CAST5-CBC`, `DES-CBC`, `DES-EDE-CBC`, `DES-EDE3-CBC`, the AES-CBC, ARIA-CBC, and Camellia-CBC families, `SEED-CBC`, `SM4-CBC`, and `NONE`. -Only available in `static_key` mode. `NONE` provides no confidentiality. +Only available in `static_key` mode. ### data_ciphers @@ -460,8 +452,7 @@ Only available in TLS mode. The AES-GCM family includes `AES-192-GCM`. Retained ciphers include the CBC, CFB, and OFB forms of AES, ARIA, Camellia, DES, Blowfish, and CAST5, the CBC, CFB, and OFB forms of SEED and SM4, and `NONE`. CFB and OFB are available only -in TLS mode. Legacy ciphers provide weaker or no confidentiality and are not -enabled by default. +in TLS mode. ### data_ciphers_fallback @@ -477,15 +468,10 @@ OpenVPN data channel authentication digest. `SHA1` is used by default. It only applies to non-AEAD data ciphers and `tls_auth`. -Legacy digests including `MD5` and `RIPEMD160` remain available when explicitly -configured for compatibility. - ### mss_fix Maximum OpenVPN UDP packet size used to clamp the MSS of TCP connections sent through the tunnel. -This prevents TCP packets from exceeding the path MTU after OpenVPN encapsulation. - When empty, the upstream OpenVPN default is used: `fragment` when configured, otherwise `1492` for the default tunnel MTU or the configured tunnel MTU. @@ -515,8 +501,6 @@ Conflict with TCP transport. UDP data-channel replay window size. `64` is used by default. The maximum is `65536`. -TCP always requires strictly consecutive packet IDs. - ### replay_window_time UDP data-channel replay window duration. `15s` is used by default and the maximum is `10m`. @@ -529,21 +513,17 @@ OpenVPN `compress` framing mode, one of `none`, `no`, `lz4`, `lz4-v2`, `stub`, ` Disabled by default. -Compression can weaken traffic confidentiality. Prefer `stub` or `stub-v2` only when framing compatibility is required. - ### compression_lzo OpenVPN `comp-lzo` mode, one of `none`, `no`, `yes`, `adaptive`, `asym`, `disabled`, or `off`. Disabled by default. -Compression can weaken traffic confidentiality. Enable it only when required by the server. - ### allow_compression Policy for compression pushed by the server, one of `no`, `asym`, or `yes`. -`no` is used by default and permits only compression stub framing. `asym` accepts compressed packets from the server but does not compress outgoing packets. For OpenVPN 2.7 compatibility, `yes` is accepted as a legacy alias for `asym`; the client never sends compressed packets. +`no` is used by default and permits only compression stub framing. `asym` accepts compressed packets from the server but does not compress outgoing packets. `yes` is a legacy alias for `asym`. Conflict with non-stub compression enabled by `compression` or `compression_lzo` when set to `no`. @@ -552,8 +532,6 @@ Conflict with non-stub compression enabled by `compression` or `compression_lzo` Ignore routes, DNS and DHCP settings, route metrics, `redirect-gateway`, `redirect-private`, `block-ipv6`, and `block-outside-dns` pushed by the server. -Interface configuration, topology, tunnel MTU, `route-gateway`, and locally configured routes are still used. - Disabled by default. ### pull_filters @@ -584,53 +562,38 @@ IPv4 and IPv6 prefixes preferred by sing-box routing for this OpenVPN endpoint. These routes are used in addition to routes accepted from the server. -They do not install operating-system routes. Select the endpoint through -sing-box route rules or its preferred-route behavior. - ### route_gateway IPv4 gateway for routes through the OpenVPN endpoint. When empty, the VPN gateway received from the server is used. -The value is retained for OpenVPN configuration compatibility; endpoint route -preference is prefix-based and does not install a system gateway route. - ### route_metric Default metric for routes through the OpenVPN endpoint. The platform default is used when `0`. -The value is retained for OpenVPN configuration compatibility and does not -install a system route. - ### redirect_gateway Prefer the OpenVPN endpoint for all IPv4 destinations in sing-box routing. Disabled by default. -This does not install an operating-system default route. - ### redirect_gateway_flags OpenVPN `redirect-gateway` flags. `!ipv4` disables IPv4 preference, `def1` represents it with two `/1` -prefixes, and `ipv6` also prefers the upstream-specific IPv6 prefixes. The -OpenVPN control connection always uses its configured outbound dialer rather -than endpoint routes, so `local` and `autolocal` require no system-route -exception. `bypass-dhcp` and `bypass-dns` are not applicable because sing-box -does not install pushed DHCP or DNS settings into the operating system. -`block-local` is unsupported because the endpoint has no cross-platform source -for the physical default gateway needed to preserve the gateway exception. +prefixes, and `ipv6` also prefers the upstream-specific IPv6 prefixes. +`local`, `autolocal`, `bypass-dhcp` and `bypass-dns` have no effect. +`block-local` is not supported. Empty by default. ### redirect_private -Accept `redirect_gateway_flags` without adding a default-route preference. Routes pushed or configured separately still affect the endpoint's preferred addresses, but no operating-system routes are installed. +Accept `redirect_gateway_flags` without adding a default-route preference. Disabled by default. @@ -707,9 +670,6 @@ Use a system interface. Requires privilege and cannot conflict with existing system interfaces. -The endpoint configures interface addresses and MTU but does not install -operating-system routes or DNS settings. - If disabled, sing-box uses the internal network stack. ### name diff --git a/docs/configuration/endpoint/openvpn-client.zh.md b/docs/configuration/endpoint/openvpn-client.zh.md index 1ee072de..fd7fdb13 100644 --- a/docs/configuration/endpoint/openvpn-client.zh.md +++ b/docs/configuration/endpoint/openvpn-client.zh.md @@ -120,8 +120,6 @@ OpenVPN 会话模式,可选值为 `tls` 或 `static_key`。 默认使用 `tls`。 `static_key` 是已弃用的 OpenVPN 模式,不使用 TLS 控制通道且不提供前向保密。 -为兼容无法修改的企业 VPN 服务器,此模式仍作为显式兼容选项保留。该模式不使用 -`tls`、用户名/密码认证、拉取选项或 TLS 重协商选项。 ### server @@ -261,7 +259,7 @@ OpenVPN 控制通道 TLS 配置。 预期的服务器证书名称。 -为空时禁用证书名称验证,但仍会验证证书链或 fingerprint 与服务器证书用途。 +为空时禁用证书名称验证。 ### tls.server_name_type @@ -319,14 +317,10 @@ OpenVPN 控制通道 TLS 配置。 每个 fingerprint 必须是不带分隔符的 64 字符小写十六进制字符串。 -同时配置受信任 CA 时,会同时验证证书链和 fingerprint。未配置受信任 CA 时,会验证 fingerprint、证书有效期、配置的名称和证书用途,但不验证证书链。 - ### tls.crl_path 用于拒绝已吊销服务器证书的 PEM 或 DER CRL 文件路径。 -根据受信任证书链验证 CRL 签名和有效期。 - 默认禁用。 ### tls.remote_certificate_ku @@ -365,7 +359,7 @@ OpenVPN 控制通道 TLS 配置。 `insecure` 为兼容不可变对端而接受使用 MD5 或 SHA-1 签名的证书链和较小的旧密钥,仅应在对端无法升级时使用。`legacy` 接受 SHA-1 但拒绝 MD5 签名;`preferred` 要求更强的签名和密钥。 -选择 `suiteb` 且 `tls.cipher` 为空时,TLS 1.2 cipher 列表默认使用 Suite B ECDHE-ECDSA AES-GCM 套件。该 profile 不限制显式配置的 `tls.cipher` 和 `tls.groups`。 +选择 `suiteb` 且 `tls.cipher` 为空时,TLS 1.2 cipher 列表默认使用 Suite B ECDHE-ECDSA AES-GCM 套件。 ### tls.ns_certificate_type @@ -391,7 +385,7 @@ OpenVPN 控制通道 TLS 配置。 TLS 1.2 及更低版本允许的 OpenSSL cipher suite 名称,以冒号分隔。 -为空时使用默认 TLS cipher suite。该字段不控制 TLS 1.3 cipher suite。 +为空时使用默认 TLS cipher suite。 ### tls.groups @@ -435,12 +429,11 @@ OpenVPN 控制通道封装。 `static_key` 模式使用的数据通道 cipher。 -为空时使用上游静态密钥模式的默认值 `BF-CBC`。`BF-CBC` 是采用 64 位 block size -的旧 cipher;应尽可能显式配置服务器要求的 cipher。静态密钥 cipher 包括 +为空时使用上游静态密钥模式的默认值 `BF-CBC`。静态密钥 cipher 包括 `BF-CBC`、`CAST5-CBC`、`DES-CBC`、`DES-EDE-CBC`、`DES-EDE3-CBC`、 AES-CBC、ARIA-CBC、Camellia-CBC 系列,以及 `SEED-CBC`、`SM4-CBC` 和 `NONE`。 -仅在 `static_key` 模式下可用。`NONE` 不提供机密性。 +仅在 `static_key` 模式下可用。 ### data_ciphers @@ -450,7 +443,7 @@ AES-CBC、ARIA-CBC、Camellia-CBC 系列,以及 `SEED-CBC`、`SM4-CBC` 和 `NO 默认使用 `AES-256-GCM`、`AES-128-GCM` 和 `CHACHA20-POLY1305`。 -AES-GCM 系列还包括 `AES-192-GCM`。保留的 cipher 包括 AES、ARIA、Camellia、DES、Blowfish、CAST5、SEED 和 SM4 的 CBC、CFB、OFB 形式,以及 `NONE`。CFB 和 OFB 仅可用于 TLS 模式。旧 cipher 只能提供较弱的机密性或完全不加密,因此默认不启用。 +AES-GCM 系列还包括 `AES-192-GCM`。保留的 cipher 包括 AES、ARIA、Camellia、DES、Blowfish、CAST5、SEED 和 SM4 的 CBC、CFB、OFB 形式,以及 `NONE`。CFB 和 OFB 仅可用于 TLS 模式。 ### data_ciphers_fallback @@ -466,14 +459,10 @@ OpenVPN 数据通道认证摘要。 默认使用 `SHA1`,仅应用于非 AEAD 数据 cipher 和 `tls_auth`。 -为兼容既有服务器,显式配置时仍可使用 `MD5` 和 `RIPEMD160` 等旧摘要。 - ### mss_fix OpenVPN UDP packet 的最大大小,用于限制通过隧道发送的 TCP 连接 MSS。 -这可以避免 TCP packet 在 OpenVPN 封装后超过 path MTU。 - 为空时使用上游 OpenVPN 默认值:配置了 `fragment` 时使用其值;否则默认 tunnel MTU 使用 `1492`,自定义 tunnel MTU 使用该 MTU。 ### mss_fix_disabled @@ -502,8 +491,6 @@ OpenVPN UDP packet 的最大大小,用于限制通过隧道发送的 TCP 连 UDP 数据通道重放窗口大小。默认使用 `64`,最大值为 `65536`。 -TCP 始终要求数据包 ID 严格连续。 - ### replay_window_time UDP 数据通道重放窗口时长。默认使用 `15s`,最大值为 `10m`。 @@ -516,21 +503,17 @@ OpenVPN `compress` framing 模式,可选值为 `none`、`no`、`lz4`、`lz4-v2 默认禁用。 -Compression 可能削弱流量机密性。仅在需要 framing 兼容性时使用 `stub` 或 `stub-v2`。 - ### compression_lzo OpenVPN `comp-lzo` 模式,可选值为 `none`、`no`、`yes`、`adaptive`、`asym`、`disabled` 或 `off`。 默认禁用。 -Compression 可能削弱流量机密性。仅在服务器要求时启用。 - ### allow_compression 服务器推送的 compression 策略,可选值为 `no`、`asym` 或 `yes`。 -默认使用 `no`,仅允许 compression stub framing。`asym` 接受来自服务器的 compressed packet,但不压缩出站 packet。为兼容 OpenVPN 2.7,`yes` 作为 `asym` 的旧别名接受;客户端绝不会发送 compressed packet。 +默认使用 `no`,仅允许 compression stub framing。`asym` 接受来自服务器的 compressed packet,但不压缩出站 packet。`yes` 是 `asym` 的旧别名。 当设为 `no` 时,与通过 `compression` 或 `compression_lzo` 启用的非 stub compression 冲突。 @@ -539,8 +522,6 @@ Compression 可能削弱流量机密性。仅在服务器要求时启用。 忽略服务器推送的 route、DNS 和 DHCP 设置、route metric、`redirect-gateway`、 `redirect-private`、`block-ipv6` 和 `block-outside-dns`。 -仍会使用接口配置、topology、tunnel MTU、`route-gateway` 和本地配置的 route。 - 默认禁用。 ### pull_filters @@ -571,43 +552,35 @@ sing-box 路由优先选择此 OpenVPN endpoint 的 IPv4 和 IPv6 前缀。 这些 route 会与从服务器接受的 route 一起使用。 -它们不会安装操作系统路由。请通过 sing-box 路由规则或 endpoint 的首选路由行为选择此 endpoint。 - ### route_gateway 通过 OpenVPN endpoint 路由的 IPv4 gateway。 为空时使用从服务器接收的 VPN gateway。 -该值仅为兼容 OpenVPN 配置而保留;endpoint 的路由偏好只按前缀判断,不会安装系统 gateway 路由。 - ### route_metric 通过 OpenVPN endpoint 路由的默认 metric。 设为 `0` 时使用平台默认值。 -该值仅为兼容 OpenVPN 配置而保留,不会安装系统路由。 - ### redirect_gateway 在 sing-box 路由中对所有 IPv4 目的地优先选择 OpenVPN endpoint。 默认禁用。 -这不会安装操作系统默认路由。 - ### redirect_gateway_flags OpenVPN `redirect-gateway` flag。 -`!ipv4` 禁用 IPv4 偏好,`def1` 使用两个 `/1` 前缀表示,`ipv6` 还会优先选择上游特定的 IPv6 前缀。OpenVPN 控制连接始终使用其配置的出站拨号器,不经过 endpoint 路由,因此 `local` 和 `autolocal` 不需要系统路由例外。由于 sing-box 不会把推送的 DHCP 或 DNS 设置安装到操作系统,`bypass-dhcp` 和 `bypass-dns` 不适用。`block-local` 不受支持,因为 endpoint 没有可跨平台获取物理默认网关的来源,无法保留网关例外。 +`!ipv4` 禁用 IPv4 偏好,`def1` 使用两个 `/1` 前缀表示,`ipv6` 还会优先选择上游特定的 IPv6 前缀。`local`、`autolocal`、`bypass-dhcp` 和 `bypass-dns` 无效果。不支持 `block-local`。 默认为空。 ### redirect_private -接受 `redirect_gateway_flags`,但不添加默认路由偏好。单独推送或配置的路由仍会影响 endpoint 的首选地址,但不会安装操作系统路由。 +接受 `redirect_gateway_flags`,但不添加默认路由偏好。 默认禁用。 @@ -683,8 +656,6 @@ Notification 之间间隔一秒。设为 `0` 时禁用。 需要权限,且不能与现有系统接口冲突。 -endpoint 会配置接口地址和 MTU,但不会安装操作系统路由或 DNS 设置。 - 禁用时,sing-box 使用内部网络栈。 ### name diff --git a/docs/configuration/endpoint/openvpn-server.md b/docs/configuration/endpoint/openvpn-server.md index 37802ba8..3b4d82a7 100644 --- a/docs/configuration/endpoint/openvpn-server.md +++ b/docs/configuration/endpoint/openvpn-server.md @@ -111,9 +111,6 @@ Use system interface. Requires privilege and cannot conflict with existing system interfaces. -The endpoint configures interface addresses and MTU but does not install -operating-system routes or DNS settings. - If disabled, sing-box uses the internal network stack. ### name @@ -135,8 +132,6 @@ OpenVPN session mode, one of `tls` or `static_key`. `tls` is used by default. `static_key` serves one peer without a TLS control channel or forward secrecy. -It is retained as an explicit compatibility option for immutable deployments. -It does not use `tls`, `users`, push options, or TLS renegotiation options. ### network @@ -145,15 +140,13 @@ OpenVPN transport network, one of `udp` or `tcp`. `udp` will be used by default. Only one transport network is served per endpoint; to serve both TCP and UDP, -configure two endpoints with separate `address` subnets, -matching upstream OpenVPN which requires two server processes. +configure two endpoints with separate `address` subnets. ### remote Fixed remote peer address for a UDP `static_key` server. -Required with `remote_port` in UDP `static_key` mode. TCP servers accept the -single peer from the listening socket and do not use this field. +Required with `remote_port` in UDP `static_key` mode. ### remote_port @@ -165,9 +158,9 @@ Required with `remote` in UDP `static_key` mode. Maximum number of established and pending TLS client sessions. -`1024` is used by default. The value must be smaller than `16777216`, the size of the OpenVPN peer-id space. +`1024` is used by default. The maximum value is `16777215`. -`static_key` mode supports one peer, so this value must be `0` or `1`. +Must be `0` or `1` in `static_key` mode. ### address @@ -317,8 +310,6 @@ If set to `optional`, a client certificate is verified when provided, but client If set to `none`, client certificates are not requested. -This field does not replace `users`; when `users` is set, username/password authentication is still required. - ### tls.client_name Expected client certificate name. Disabled when empty. @@ -360,7 +351,7 @@ keys for compatibility with immutable peers. Use it only when the peer cannot be upgraded. `legacy` accepts SHA-1 but rejects MD5 signatures; `preferred` requires stronger signatures and keys. -When `suiteb` is selected and `tls.cipher` is empty, the TLS 1.2 cipher list defaults to the Suite B ECDHE-ECDSA AES-GCM suites. Explicit `tls.cipher` and `tls.groups` values are not restricted by the profile. +When `suiteb` is selected and `tls.cipher` is empty, the TLS 1.2 cipher list defaults to the Suite B ECDHE-ECDSA AES-GCM suites. ### tls.ns_certificate_type @@ -378,7 +369,7 @@ Maximum TLS version. The maximum supported version is used by default. Colon-separated OpenSSL cipher suite names allowed for TLS 1.2 and earlier. -The default TLS cipher suites are used when empty. TLS 1.3 cipher suites are not controlled by this field. +The default TLS cipher suites are used when empty. ### tls.groups @@ -443,7 +434,7 @@ The upstream static-key default `BF-CBC` is used when empty. Supported static-key ciphers are the AES-CBC, ARIA-CBC, Camellia-CBC, DES-CBC, Blowfish-CBC, CAST5-CBC families, `SEED-CBC`, `SM4-CBC`, and `NONE`. -Only available in `static_key` mode. `NONE` provides no confidentiality. +Only available in `static_key` mode. ### data_ciphers @@ -454,8 +445,7 @@ Allowed OpenVPN data channel ciphers. The AES-GCM family includes `AES-192-GCM`. Retained ciphers include the CBC, CFB, and OFB forms of AES, ARIA, Camellia, DES, Blowfish, and CAST5, the CBC, CFB, and OFB forms of SEED and SM4, and `NONE`. CFB and OFB are available only -in TLS mode. Legacy ciphers provide weaker or no confidentiality and are not -enabled by default. +in TLS mode. Only available in TLS mode. @@ -473,10 +463,7 @@ Only available in TLS mode. OpenVPN data channel authentication digest. -`SHA1` will be used by default, matching the upstream default; it only applies to non-AEAD data ciphers and `tls_auth`. - -Legacy digests including `MD5` and `RIPEMD160` remain available when explicitly -configured for compatibility. +`SHA1` will be used by default. It only applies to non-AEAD data ciphers and `tls_auth`. ### mss_fix @@ -492,7 +479,7 @@ Calculation mode for an explicit `mss_fix`, one of `mtu` or `fixed`. Requires `m ### replay_window -UDP data-channel replay window size. `64` is used by default; TCP packet IDs remain strictly consecutive. +UDP data-channel replay window size. `64` is used by default. ### replay_window_time @@ -582,8 +569,6 @@ Time without receiving a packet after which the server closes the client session This value applies to the server. Use `push.ping_restart` to configure clients. -The server timeout should be longer than the client timeout so the client can reconnect before the server discards its session. - The value must use whole seconds. Disabled by default. diff --git a/docs/configuration/endpoint/openvpn-server.zh.md b/docs/configuration/endpoint/openvpn-server.zh.md index 2ebdbce1..4738e202 100644 --- a/docs/configuration/endpoint/openvpn-server.zh.md +++ b/docs/configuration/endpoint/openvpn-server.zh.md @@ -111,8 +111,6 @@ 需要特权且不能与已有系统接口冲突。 -endpoint 会配置接口地址和 MTU,但不会安装操作系统路由或 DNS 设置。 - 如果禁用,sing-box 将使用内部网络栈。 ### name @@ -133,7 +131,7 @@ OpenVPN 会话模式,`tls` 或 `static_key` 之一。 默认使用 `tls`。 -`static_key` 在没有 TLS 控制信道和前向保密的情况下服务一个对端,仅作为不可变部署的显式兼容选项保留。该模式不使用 `tls`、`users`、推送选项或 TLS 重协商选项。 +`static_key` 在没有 TLS 控制信道和前向保密的情况下服务一个对端。 ### network @@ -142,14 +140,13 @@ OpenVPN 传输网络,`udp` 或 `tcp` 之一。 默认使用 `udp`。 每个端点仅服务一种传输网络;如需同时服务 TCP 与 UDP, -需要配置两个端点并使用互不重叠的 `address` 子网, -与上游 OpenVPN 需要两个服务进程一致。 +需要配置两个端点并使用互不重叠的 `address` 子网。 ### remote UDP `static_key` 服务器的固定远端地址。 -在 UDP `static_key` 模式下与 `remote_port` 一起必填。TCP 服务器从监听套接字接受单个对端,不使用此字段。 +在 UDP `static_key` 模式下与 `remote_port` 一起必填。 ### remote_port @@ -161,9 +158,9 @@ UDP `static_key` 服务器的固定远端端口。 已建立与握手中的 TLS 客户端会话的最大数量。 -默认使用 `1024`。该值必须小于 OpenVPN peer-id 空间的大小 `16777216`。 +默认使用 `1024`。最大值为 `16777215`。 -`static_key` 模式仅支持一个对端,因此此值必须为 `0` 或 `1`。 +在 `static_key` 模式下必须为 `0` 或 `1`。 ### address @@ -311,8 +308,6 @@ OpenVPN 客户端证书策略,`require`、`optional` 或 `none` 之一。 设为 `none` 时,不请求客户端证书。 -该字段不替代 `users`;设置 `users` 后仍然要求用户名/密码认证。 - ### tls.client_name 期望的客户端证书名称。为空时禁用。 @@ -351,7 +346,7 @@ OpenVPN `remote-cert-ku` 格式的客户端证书 Key Usage mask。 `insecure` 为兼容不可变对端而接受使用 MD5 或 SHA-1 签名的证书链和较小的旧密钥,仅应在对端无法升级时使用。`legacy` 接受 SHA-1 但拒绝 MD5 签名;`preferred` 要求更强的签名和密钥。 -选择 `suiteb` 且 `tls.cipher` 为空时,TLS 1.2 cipher 列表默认使用 Suite B ECDHE-ECDSA AES-GCM 套件。该 profile 不限制显式配置的 `tls.cipher` 和 `tls.groups`。 +选择 `suiteb` 且 `tls.cipher` 为空时,TLS 1.2 cipher 列表默认使用 Suite B ECDHE-ECDSA AES-GCM 套件。 ### tls.ns_certificate_type @@ -369,7 +364,7 @@ OpenVPN `remote-cert-ku` 格式的客户端证书 Key Usage mask。 TLS 1.2 及更低版本允许的 OpenSSL cipher suite 名称,以冒号分隔。 -为空时使用默认 TLS cipher suite。该字段不控制 TLS 1.3 cipher suite。 +为空时使用默认 TLS cipher suite。 ### tls.groups @@ -431,7 +426,7 @@ OpenVPN `tls-auth` 密钥方向,`server` 或 `client` 之一。 为空时使用上游静态密钥模式的默认值 `BF-CBC`。支持 AES-CBC、ARIA-CBC、Camellia-CBC、DES-CBC、Blowfish-CBC、CAST5-CBC 系列,以及 `SEED-CBC`、`SM4-CBC` 和 `NONE`。 -仅在 `static_key` 模式下可用。`NONE` 不提供机密性。 +仅在 `static_key` 模式下可用。 ### data_ciphers @@ -439,7 +434,7 @@ OpenVPN `tls-auth` 密钥方向,`server` 或 `client` 之一。 默认使用 `AES-256-GCM`、`AES-128-GCM` 和 `CHACHA20-POLY1305`。 -AES-GCM 系列还包括 `AES-192-GCM`。保留的 cipher 包括 AES、ARIA、Camellia、DES、Blowfish、CAST5、SEED 和 SM4 的 CBC、CFB、OFB 形式,以及 `NONE`。CFB 和 OFB 仅可用于 TLS 模式。旧 cipher 只能提供较弱的机密性或完全不加密,因此默认不启用。 +AES-GCM 系列还包括 `AES-192-GCM`。保留的 cipher 包括 AES、ARIA、Camellia、DES、Blowfish、CAST5、SEED 和 SM4 的 CBC、CFB、OFB 形式,以及 `NONE`。CFB 和 OFB 仅可用于 TLS 模式。 仅在 TLS 模式下可用。 @@ -457,9 +452,7 @@ AES-GCM 系列还包括 `AES-192-GCM`。保留的 cipher 包括 AES、ARIA、Cam OpenVPN 数据信道认证摘要。 -默认使用 `SHA1`,与上游默认值一致;仅对非 AEAD 数据信道加密方式和 `tls_auth` 生效。 - -为兼容既有客户端,显式配置时仍可使用 `MD5` 和 `RIPEMD160` 等旧摘要。 +默认使用 `SHA1`,仅对非 AEAD 数据信道加密方式和 `tls_auth` 生效。 ### mss_fix @@ -475,7 +468,7 @@ OpenVPN 数据信道认证摘要。 ### replay_window -UDP 数据通道重放窗口大小。默认使用 `64`;TCP 数据包 ID 始终严格连续。 +UDP 数据通道重放窗口大小。默认使用 `64`。 ### replay_window_time @@ -565,8 +558,6 @@ IPv4 和 IPv6 前缀可以混用。 该值应用于服务器。使用 `push.ping_restart` 配置客户端。 -服务器超时应长于客户端超时,以便客户端在服务器丢弃其会话前重新连接。 - 该值必须使用整秒。 默认禁用。 diff --git a/docs/configuration/endpoint/tailscale.md b/docs/configuration/endpoint/tailscale.md index 00d40154..a66dcd19 100644 --- a/docs/configuration/endpoint/tailscale.md +++ b/docs/configuration/endpoint/tailscale.md @@ -177,9 +177,9 @@ Run a Tailscale SSH server on tailnet port 22. Access is controlled by the SSH ACL in the Tailscale admin console, which maps each connection to a local user. How that user is resolved, and which users are allowed, depends on the platform: -- **Linux** and **macOS**: the user is resolved from the system user database. Switching to a user other than the one sing-box runs as requires running as root; without root, sessions are limited to the current user. -- **Windows**: in the command line client, sessions run as the sing-box process identity; the mapped user is not impersonated, so a session mapped to a different local account is refused. In the graphical client, there is no such restriction. -- **Android**: the user is resolved by the app rather than the system user database. `root` is the superuser (UID 0) and `shell` is the ADB shell user (UID 2000); every other name is resolved as the package name of an installed application, running as that application's UID with its data directory as the home directory, so the target application must be installed. `termux` is a shortcut for `com.termux`, and `sing-box` for the app's own package name; when Termux is installed, the `root` and `termux` users load the Termux environment. Running as the sing-box application itself requires no root, while any other user requires granted root access; without root, sessions are limited to the sing-box user. +- **Linux** and **macOS**: switching to a user other than the one sing-box runs as requires running as root. +- **Windows**: in the command line client, sessions mapped to a local account other than the one sing-box runs as are refused. In the graphical client, there is no such restriction. +- **Android**: `root` is the superuser (UID 0) and `shell` is the ADB shell user (UID 2000); every other name is resolved as the package name of an installed application, running as that application's UID with its data directory as the home directory. `termux` is a shortcut for `com.termux`, and `sing-box` for the app's own package name; when Termux is installed, the `root` and `termux` users load the Termux environment. Running as the sing-box application itself requires no root, while any other user requires granted root access. - **macOS**: the SSH server is only available in the standalone version and requires the Helper Service; the App Store version is not supported. - **iOS**: the SSH server is only available in the jailbreak build; the App Store and TestFlight versions are not supported. - **tvOS**: not yet supported. diff --git a/docs/configuration/endpoint/tailscale.zh.md b/docs/configuration/endpoint/tailscale.zh.md index adda6425..887f279c 100644 --- a/docs/configuration/endpoint/tailscale.zh.md +++ b/docs/configuration/endpoint/tailscale.zh.md @@ -176,9 +176,9 @@ UDP NAT 过期时间。 访问控制由 Tailscale 管理控制台中的 SSH ACL 决定,它将每个连接映射到一个本地用户。该用户如何解析、以及允许哪些用户,取决于平台: -- **Linux** 和 **macOS**:从系统用户数据库解析用户。要切换到 sing-box 运行身份以外的用户需要以 root 运行;非 root 时,会话仅限于当前用户。 -- **Windows**:在命令行客户端中,会话以 sing-box 进程的身份运行;映射的用户不会被模拟,因此映射到其他本地账户的会话将被拒绝。在图形客户端中没有此限制。 -- **Android**:用户由应用解析,而非系统用户数据库。`root` 即超级用户(UID 0),`shell` 为 ADB shell 用户(UID 2000);其他名称均作为已安装应用的包名解析,以该应用的 UID 运行,并使用其数据目录作为主目录,因此目标应用必须已安装。`termux` 是 `com.termux` 的快捷方式,`sing-box` 是应用自身包名的快捷方式;当 Termux 已安装时,`root` 和 `termux` 用户将加载 Termux 环境。以 sing-box 应用自身身份运行无需 root,其他用户则需要已授予的 root 权限;非 root 时,会话仅限于 sing-box 用户。 +- **Linux** 和 **macOS**:要切换到 sing-box 运行身份以外的用户需要以 root 运行。 +- **Windows**:在命令行客户端中,映射到 sing-box 运行身份以外的本地账户的会话将被拒绝。在图形客户端中没有此限制。 +- **Android**:`root` 即超级用户(UID 0),`shell` 为 ADB shell 用户(UID 2000);其他名称均作为已安装应用的包名解析,以该应用的 UID 运行,并使用其数据目录作为主目录。`termux` 是 `com.termux` 的快捷方式,`sing-box` 是应用自身包名的快捷方式;当 Termux 已安装时,`root` 和 `termux` 用户将加载 Termux 环境。以 sing-box 应用自身身份运行无需 root,其他用户则需要已授予的 root 权限。 - **macOS**:SSH 服务器仅在独立版本中可用,且需要辅助服务;App Store 版本不支持。 - **iOS**:SSH 服务器仅在越狱版本中可用;App Store 和 TestFlight 版本不支持。 - **tvOS**:暂不支持。 diff --git a/docs/configuration/inbound/hysteria2.md b/docs/configuration/inbound/hysteria2.md index 7b5b07a0..4919217d 100644 --- a/docs/configuration/inbound/hysteria2.md +++ b/docs/configuration/inbound/hysteria2.md @@ -203,8 +203,6 @@ Enable debug information logging for Hysteria Brutal CC. Register this inbound to a Hysteria Realm rendezvous service to enable NAT traversal. -The inbound discovers its public addresses via STUN, registers them on the realm, and uses UDP hole-punching to accept incoming clients without a publicly reachable listen address. - See [Hysteria Realm](/configuration/service/hysteria-realm/) for the rendezvous service. #### realm.server_url @@ -255,7 +253,7 @@ The `listen` address must be compatible with the selected version. Maintain a UDP port mapping on the local gateway via UPnP or NAT-PMP. -The mapping is established before STUN discovery and improves hole-punching reliability behind gateways that support it; failures are non-fatal. +Improves hole-punching reliability behind gateways that support it. Requires IPv4: conflicts with `"ip_version": 6`. @@ -271,7 +269,7 @@ Timeout for gateway discovery and mapping operations. #### realm.port_mapping.lifetime -Lease lifetime of the mapping; it is renewed at half the lifetime. +Lease lifetime of the mapping. `10m` is used by default. diff --git a/docs/configuration/inbound/hysteria2.zh.md b/docs/configuration/inbound/hysteria2.zh.md index daf83b6f..9fc136df 100644 --- a/docs/configuration/inbound/hysteria2.zh.md +++ b/docs/configuration/inbound/hysteria2.zh.md @@ -200,8 +200,6 @@ BBR 拥塞控制算法配置,可选 `conservative` `standard` `aggressive`。 将此入站注册到 Hysteria Realm 会合服务,以启用 NAT 穿透。 -入站通过 STUN 发现自己的公网地址并注册到 realm,借助 UDP 打洞接受客户端连接,无需可公网直达的监听地址。 - 会合服务参阅 [Hysteria Realm](/zh/configuration/service/hysteria-realm/)。 #### realm.server_url @@ -252,7 +250,7 @@ Realm 上的槽位标识符。 通过 UPnP 或 NAT-PMP 在本地网关上维护 UDP 端口映射。 -映射在 STUN 发现之前建立,可在支持的网关后提升打洞成功率;失败不影响正常流程。 +可在支持的网关后提升打洞成功率。 需要 IPv4:与 `"ip_version": 6` 冲突。 @@ -268,7 +266,7 @@ Realm 上的槽位标识符。 #### realm.port_mapping.lifetime -映射的租约时长;每过一半时长续期一次。 +映射的租约时长。 默认使用 `10m`。 diff --git a/docs/configuration/inbound/snell.md b/docs/configuration/inbound/snell.md index 9fb6fe97..e7451052 100644 --- a/docs/configuration/inbound/snell.md +++ b/docs/configuration/inbound/snell.md @@ -77,7 +77,7 @@ The pre-shared key. Snell users. When set, the server runs in multi-user mode: each entry has a `name` (optional, used in -logs) and a `userkey` (the user's key). The top-level `psk` remains the server key. +logs) and a `userkey` (the user's key). #### obfs_mode diff --git a/docs/configuration/inbound/snell.zh.md b/docs/configuration/inbound/snell.zh.md index f0051c8f..605b06cb 100644 --- a/docs/configuration/inbound/snell.zh.md +++ b/docs/configuration/inbound/snell.zh.md @@ -77,7 +77,7 @@ Snell 协议版本,`5` `6` 之一。 Snell 用户。 设置后,服务器运行于多用户模式:每一项包含 `name`(可选,用于日志)和 `userkey` -(用户密钥)。顶层的 `psk` 仍作为服务器密钥。 +(用户密钥)。 #### obfs_mode diff --git a/docs/configuration/inbound/tun.md b/docs/configuration/inbound/tun.md index ec8829ea..33653755 100644 --- a/docs/configuration/inbound/tun.md +++ b/docs/configuration/inbound/tun.md @@ -259,21 +259,16 @@ How DNS is handled on the TUN interface. `hijack` adds the following on top of `native`: -*On Linux*: only DNS sent to non-local destinations can be intercepted. -Traffic destined to addresses on the host's own interfaces (such as -`127.0.0.53` or the host's LAN-side IP) is delivered through the kernel -`local` routing table before any user rule applies, and `OUTPUT` NAT cannot -redirect packets going through `lo`. +*On Linux*: DNS sent to addresses on the host's own interfaces (such as +`127.0.0.53` or the host's LAN-side IP) is not hijacked. -- Without `auto_redirect`, an `iproute2` rule makes port 53 skip the `main` - table's specific-route lookup, forcing DNS that would otherwise be - delivered through a directly-attached subnet through the TUN. Destination - addresses are not rewritten. -- With `auto_redirect`, an nftables rule DNATs port 53 traffic directly to +- Without `auto_redirect`, port 53 traffic to directly-attached subnets is + also routed through the TUN. +- With `auto_redirect`, port 53 traffic is redirected to [`dns_address`](#dns_address). -*On Windows with [`strict_route`](#strict_route)*: a WFP filter blocks port -53 traffic going through interfaces other than the TUN. +*On Windows with [`strict_route`](#strict_route)*: port 53 traffic going +through interfaces other than the TUN is blocked. #### dns_address @@ -281,15 +276,12 @@ redirect packets going through `lo`. List of DNS server addresses used by [`dns_mode`](#dns_mode). -When unset, sing-box derives one address per family by taking the next IP after -the first IPv4/IPv6 entry in [`address`](#address). Connections toward those -derived addresses are additionally hijacked into the sing-box DNS module, -equivalent to a [`hijack-dns`](/configuration/route/rule_action/#hijack-dns) -route action; this preserves the behaviour from before this option was added. +When unset, the next address after the first IPv4 and IPv6 entry in +[`address`](#address) is used, and connections to it are handled as a +[`hijack-dns`](/configuration/route/rule_action/#hijack-dns) route action. -When set, this auto-hijack is not applied; configure an explicit -[`hijack-dns`](/configuration/route/rule_action/#hijack-dns) route rule if the -behaviour is still required. +When set, configure a [`hijack-dns`](/configuration/route/rule_action/#hijack-dns) +route rule to handle DNS traffic to these addresses. #### gso diff --git a/docs/configuration/inbound/tun.zh.md b/docs/configuration/inbound/tun.zh.md index 726d34e8..2bbe1a50 100644 --- a/docs/configuration/inbound/tun.zh.md +++ b/docs/configuration/inbound/tun.zh.md @@ -263,17 +263,13 @@ TUN 接口上 DNS 的处理方式。 `hijack` 在 `native` 之上额外执行: -*Linux*:只能劫持发往非本机地址的 DNS。发往本机接口地址(如 `127.0.0.53` -或本机 LAN 接口 IP)的流量由内核 `local` 路由表在所有用户规则之前直接交付, -`OUTPUT` 链 NAT 也无法对走 `lo` 的包生效。 +*Linux*:发往本机接口地址(如 `127.0.0.53` 或本机 LAN 接口 IP)的 DNS 不会被劫持。 -- 未启用 `auto_redirect` 时:通过 `iproute2` 规则让 53 端口跳过 `main` 表的 - 具体路由查找,把本来会经直连子网直接送达的 DNS 改走 TUN —— 不重写目的地址。 -- 启用 `auto_redirect` 时:通过 nftables 规则将 53 端口流量直接 DNAT 至 +- 未启用 `auto_redirect` 时:发往直连子网的 53 端口流量也会经由 TUN 路由。 +- 启用 `auto_redirect` 时:53 端口流量被重定向至 [`dns_address`](#dns_address)。 -*Windows 启用 [`strict_route`](#strict_route) 时*:通过 WFP 过滤器阻止经由非 -TUN 接口的 53 端口流量。 +*Windows 启用 [`strict_route`](#strict_route) 时*:阻止经由非 TUN 接口的 53 端口流量。 #### dns_address @@ -281,13 +277,11 @@ TUN 接口的 53 端口流量。 [`dns_mode`](#dns_mode) 使用的 DNS 服务器地址列表。 -未设置时,sing-box 会按地址族在 [`address`](#address) 的第一个 IPv4/IPv6 -条目后面取下一个 IP 作为 DNS 服务器地址,并将流向这些推导地址的连接额外劫持到 -sing-box DNS 模块,等价于一条 -[`hijack-dns`](/zh/configuration/route/rule_action/#hijack-dns) 路由动作;这与此选项加入之前的行为一致。 +未设置时,使用 [`address`](#address) 中第一个 IPv4 和 IPv6 条目的下一个地址, +发往该地址的连接按 [`hijack-dns`](/zh/configuration/route/rule_action/#hijack-dns) 路由动作处理。 -设置后,将不再自动劫持;如仍需此行为,请显式配置 -[`hijack-dns`](/zh/configuration/route/rule_action/#hijack-dns) 路由规则。 +设置后,请配置 [`hijack-dns`](/zh/configuration/route/rule_action/#hijack-dns) +路由规则以处理发往这些地址的 DNS 流量。 #### gso diff --git a/docs/configuration/outbound/bridge.md b/docs/configuration/outbound/bridge.md index aa93aa04..201afd58 100644 --- a/docs/configuration/outbound/bridge.md +++ b/docs/configuration/outbound/bridge.md @@ -48,8 +48,6 @@ Interface name for forwarded traffic to egress. The default interface will be used by default. -Forwarded traffic will be dropped while the interface is unavailable. - #### bridge_name Custom bridge TUN interface name prefix, `bridge` is used by default. diff --git a/docs/configuration/outbound/bridge.zh.md b/docs/configuration/outbound/bridge.zh.md index 25c8b44f..ec97accf 100644 --- a/docs/configuration/outbound/bridge.zh.md +++ b/docs/configuration/outbound/bridge.zh.md @@ -43,8 +43,6 @@ icon: material/new-box 默认使用默认接口。 -接口不可用期间,转发流量将被丢弃。 - #### bridge_name 自定义 bridge TUN 接口名前缀,默认使用 `bridge`。 diff --git a/docs/configuration/outbound/hysteria2.md b/docs/configuration/outbound/hysteria2.md index 70c3c277..799a90f6 100644 --- a/docs/configuration/outbound/hysteria2.md +++ b/docs/configuration/outbound/hysteria2.md @@ -197,7 +197,7 @@ maximums. !!! warning "" Chrome does not declare support for Ed25519, so a server using an Ed25519 certificate will fail the - handshake. Use an ECDSA or RSA certificate instead; certificates issued by ACME are unaffected. + handshake. Use an ECDSA or RSA certificate instead. #### realm @@ -205,8 +205,6 @@ maximums. Connect to a Hysteria2 server through a Hysteria Realm rendezvous service. -The outbound queries the realm for the server's current public addresses, performs UDP hole-punching, and proceeds with the normal QUIC handshake. - Conflicts with `server`, `server_port` and `server_ports`. The TLS SNI defaults to the host portion of `server_url`. Set `tls.server_name` to match the certificate the Hysteria2 server presents. @@ -247,7 +245,7 @@ Restrict realm connections (STUN, hole punching, and the resulting QUIC path) to Maintain a UDP port mapping on the local gateway via UPnP or NAT-PMP. -The mapping is established before STUN discovery and improves hole-punching reliability behind gateways that support it; failures are non-fatal. +Improves hole-punching reliability behind gateways that support it. Requires IPv4: conflicts with `"ip_version": 6`. @@ -263,7 +261,7 @@ Timeout for gateway discovery and mapping operations. #### realm.port_mapping.lifetime -Lease lifetime of the mapping; it is renewed at half the lifetime. +Lease lifetime of the mapping. `10m` is used by default. diff --git a/docs/configuration/outbound/hysteria2.zh.md b/docs/configuration/outbound/hysteria2.zh.md index 042f5e75..f6be0012 100644 --- a/docs/configuration/outbound/hysteria2.zh.md +++ b/docs/configuration/outbound/hysteria2.zh.md @@ -193,7 +193,7 @@ BBR 拥塞控制算法配置,可选 `conservative` `standard` `aggressive`。 !!! warning "" Chrome 不声明支持 Ed25519,因此使用 Ed25519 证书的服务端将无法完成握手。 - 请改用 ECDSA 或 RSA 证书;由 ACME 签发的证书不受影响。 + 请改用 ECDSA 或 RSA 证书。 #### realm @@ -201,8 +201,6 @@ BBR 拥塞控制算法配置,可选 `conservative` `standard` `aggressive`。 通过 Hysteria Realm 会合服务连接 Hysteria2 服务器。 -出站从 realm 查询服务器当前的公网地址,执行 UDP 打洞,然后进行常规的 QUIC 握手。 - 与 `server`、`server_port` 和 `server_ports` 冲突。 TLS SNI 默认使用 `server_url` 中的主机名。需设置 `tls.server_name` 以匹配 Hysteria2 服务器证书覆盖的名字。 @@ -243,7 +241,7 @@ Realm 的 Bearer 令牌,需与 realm 上配置的 `users[].token` 之一匹配 通过 UPnP 或 NAT-PMP 在本地网关上维护 UDP 端口映射。 -映射在 STUN 发现之前建立,可在支持的网关后提升打洞成功率;失败不影响正常流程。 +可在支持的网关后提升打洞成功率。 需要 IPv4:与 `"ip_version": 6` 冲突。 @@ -259,7 +257,7 @@ Realm 的 Bearer 令牌,需与 realm 上配置的 `users[].token` 之一匹配 #### realm.port_mapping.lifetime -映射的租约时长;每过一半时长续期一次。 +映射的租约时长。 默认使用 `10m`。 diff --git a/docs/configuration/rule-set/headless-rule.md b/docs/configuration/rule-set/headless-rule.md index 81a5e9a0..a2218361 100644 --- a/docs/configuration/rule-set/headless-rule.md +++ b/docs/configuration/rule-set/headless-rule.md @@ -137,10 +137,8 @@ icon: material/new-box When a DNS rule references this rule-set, this field now also applies when the DNS rule is matched from an internal domain resolution that - does not target a specific DNS server. In earlier versions, only DNS - queries received from a client evaluated this field. See - [Migration](/migration/#ip_version-and-query_type-behavior-changes-in-dns-rules) - for the full list. + does not target a specific DNS server. See + [Migration](/migration/#ip_version-and-query_type-behavior-changes-in-dns-rules). When a DNS rule references a rule-set containing this field, the DNS rule is incompatible in the same DNS configuration with Legacy Address diff --git a/docs/configuration/rule-set/headless-rule.zh.md b/docs/configuration/rule-set/headless-rule.zh.md index ad78ffe4..a3e00dd0 100644 --- a/docs/configuration/rule-set/headless-rule.zh.md +++ b/docs/configuration/rule-set/headless-rule.zh.md @@ -136,8 +136,7 @@ icon: material/new-box !!! quote "sing-box 1.14.0 中的更改" 当 DNS 规则引用此规则集时,此字段现在也会在 DNS 规则被未指定具体 - DNS 服务器的内部域名解析匹配时生效。此前只有来自客户端的 DNS 查询 - 才会评估此字段。完整列表参阅 + DNS 服务器的内部域名解析匹配时生效。参阅 [迁移指南](/zh/migration/#dns-规则中的-ip_version-和-query_type-行为更改)。 当 DNS 规则引用了包含此字段的规则集时,该 DNS 规则在同一 DNS 配置中 diff --git a/docs/configuration/rule-set/index.md b/docs/configuration/rule-set/index.md index 1d4ca942..42b35c82 100644 --- a/docs/configuration/rule-set/index.md +++ b/docs/configuration/rule-set/index.md @@ -129,9 +129,7 @@ Download URL of rule-set. File path of the initial rule-set content. -Read once at startup when no cached rule-set is available, so startup is not -blocked by the initial download. The rule-set is still updated in the background -immediately after startup. +Used at startup when no cached rule-set is available. #### http_client diff --git a/docs/configuration/rule-set/index.zh.md b/docs/configuration/rule-set/index.zh.md index 69cedf1a..f4ccb044 100644 --- a/docs/configuration/rule-set/index.zh.md +++ b/docs/configuration/rule-set/index.zh.md @@ -128,8 +128,7 @@ 规则集初始内容的文件路径。 -仅在启动时没有可用的规则集缓存时读取一次,使启动不被初始下载阻塞。 -启动后规则集仍会立即在后台更新。 +在启动时没有可用的规则集缓存时使用。 #### http_client diff --git a/docs/configuration/schema.md b/docs/configuration/schema.md index 210e2bf5..95920525 100644 --- a/docs/configuration/schema.md +++ b/docs/configuration/schema.md @@ -22,7 +22,6 @@ Compatible editors can use it for completion and validation. #### $schema The schema URI used by compatible editors. -This field does not affect sing-box runtime behavior. The schema published with this documentation is available at [sing-box.sagernet.org/schema.json](https://sing-box.sagernet.org/schema.json). diff --git a/docs/configuration/schema.zh.md b/docs/configuration/schema.zh.md index 26c85264..7c289f9f 100644 --- a/docs/configuration/schema.zh.md +++ b/docs/configuration/schema.zh.md @@ -22,7 +22,6 @@ sing-box 为配置文件提供 JSON Schema Draft 2020-12。 #### $schema 兼容编辑器使用的 Schema URI。 -该字段不影响 sing-box 的运行行为。 随本文档发布的 Schema 位于 [sing-box.sagernet.org/schema.json](https://sing-box.sagernet.org/schema.json)。 diff --git a/docs/configuration/service/api.md b/docs/configuration/service/api.md index b7712050..a2797d40 100644 --- a/docs/configuration/service/api.md +++ b/docs/configuration/service/api.md @@ -12,9 +12,7 @@ It can be accessed by the [sing-box graphical clients](/clients/) for iOS, macOS Android (via the Remote Control feature), or the [sing-box dashboard](https://github.com/SagerNet/sing-box-dashboard). -The server also accepts [gRPC-Web](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md) requests, -including the WebSocket transport of [@improbable-eng/grpc-web](https://github.com/improbable-eng/grpc-web) -for bidirectional streaming methods. +The server also accepts [gRPC-Web](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md) requests. ### Structure @@ -80,9 +78,7 @@ Directory the dashboard files are stored in. `dashboard` in the working directory will be used by default. -If the directory is empty, the dashboard is downloaded and an `.etag` file is stored inside -it to skip unchanged updates. A non-empty directory without an `.etag` file is served as-is -and never updated automatically. +If the directory contains files not downloaded by sing-box, they are served as-is. ##### download_url @@ -96,8 +92,6 @@ HTTP client used to download the dashboard. See [HTTP Client Fields](/configuration/shared/http-client/) for details. -Not used when the dashboard directory contains user-provided files. - ##### update_interval Update interval of the dashboard. diff --git a/docs/configuration/service/api.zh.md b/docs/configuration/service/api.zh.md index 5b51ef3e..6d03131b 100644 --- a/docs/configuration/service/api.zh.md +++ b/docs/configuration/service/api.zh.md @@ -10,8 +10,7 @@ sing-box API 服务是用于观察与控制正在运行的 sing-box 实例的 gR 它可以由 iOS、macOS 和 Android 上的 [sing-box 图形客户端](/zh/clients/)(通过 Remote Control 功能)或 [sing-box dashboard](https://github.com/SagerNet/sing-box-dashboard) 访问。 -服务器同时接受 [gRPC-Web](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md) 请求, -包括用于双向流方法的 [@improbable-eng/grpc-web](https://github.com/improbable-eng/grpc-web) WebSocket 传输。 +服务器同时接受 [gRPC-Web](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md) 请求。 ### 结构 @@ -76,8 +75,7 @@ API 密钥。 默认使用工作目录下的 `dashboard`。 -如果目录为空,将下载仪表板,并在其中存放 `.etag` 文件以跳过未变更的更新。 -非空且不含 `.etag` 文件的目录将按原样提供,且不会自动更新。 +如果目录中包含非 sing-box 下载的文件,将按原样提供。 ##### download_url @@ -91,8 +89,6 @@ API 密钥。 参阅 [HTTP 客户端字段](/zh/configuration/shared/http-client/)。 -当仪表板目录包含用户提供的文件时不使用。 - ##### update_interval 仪表板的更新间隔。 diff --git a/docs/configuration/service/hysteria-realm.md b/docs/configuration/service/hysteria-realm.md index 0fe01f1e..a56861f9 100644 --- a/docs/configuration/service/hysteria-realm.md +++ b/docs/configuration/service/hysteria-realm.md @@ -8,9 +8,7 @@ icon: material/new-box Hysteria Realm is a rendezvous service for Hysteria2 NAT traversal. -A Hysteria2 server behind NAT registers its STUN-discovered public addresses to a stable realm endpoint; clients query the realm to learn the server's current addresses and perform UDP hole-punching to establish a direct QUIC connection. - -The realm only carries control-plane signaling. Once hole-punching succeeds, all proxy traffic flows directly between client and server. +Hysteria2 servers behind NAT register on the realm via the [`realm`](/configuration/inbound/hysteria2/#realm) inbound field, and clients connect to them through the realm via the [`realm`](/configuration/outbound/hysteria2/#realm) outbound field. ### Structure diff --git a/docs/configuration/service/hysteria-realm.zh.md b/docs/configuration/service/hysteria-realm.zh.md index c01d744b..4eb6cca7 100644 --- a/docs/configuration/service/hysteria-realm.zh.md +++ b/docs/configuration/service/hysteria-realm.zh.md @@ -8,9 +8,7 @@ icon: material/new-box Hysteria Realm 是用于 Hysteria2 NAT 穿透的会合服务。 -位于 NAT 后面的 Hysteria2 服务器将其通过 STUN 发现的公网地址注册到一个稳定的 realm 端点;客户端从 realm 查询服务器当前的地址并执行 UDP 打洞,以建立直连的 QUIC 连接。 - -Realm 只承载控制信令。打洞成功后,所有代理流量在客户端和服务器之间直连传输。 +位于 NAT 后面的 Hysteria2 服务器通过 [`realm`](/zh/configuration/inbound/hysteria2/#realm) 入站字段注册到 realm,客户端通过 [`realm`](/zh/configuration/outbound/hysteria2/#realm) 出站字段经由 realm 连接到这些服务器。 ### 结构 diff --git a/docs/configuration/service/usbip-client.md b/docs/configuration/service/usbip-client.md index 3c7c99eb..0838321a 100644 --- a/docs/configuration/service/usbip-client.md +++ b/docs/configuration/service/usbip-client.md @@ -27,12 +27,6 @@ The server must be a sing-box (or sing-usbip) server. } ``` -!!! info "Difference from the official USB/IP protocol" - - sing-box uses [sing-usbip](https://github.com/SagerNet/sing-usbip), which uses an additional - set of protocols to support enhancements such as hotplug, while remaining interoperable with - the standard USB/IP protocol. - ### Dial Fields See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/service/usbip-client.zh.md b/docs/configuration/service/usbip-client.zh.md index 853ac575..78acc74f 100644 --- a/docs/configuration/service/usbip-client.zh.md +++ b/docs/configuration/service/usbip-client.zh.md @@ -26,10 +26,6 @@ USB/IP Client 服务通过 [USB/IP](https://usbip.sourceforge.net/) 导入由 [U } ``` -!!! info "与官方 USB/IP 协议的区别" - - sing-box 使用 [sing-usbip](https://github.com/SagerNet/sing-usbip),它使用一套附加协议来支持热插拔等增强功能,但仍然可以与标准 USB/IP 互操作。 - ### 拨号字段 参阅 [拨号字段](/zh/configuration/shared/dial/) 了解详情。 diff --git a/docs/configuration/service/usbip-server.md b/docs/configuration/service/usbip-server.md index 6f972202..526171fd 100644 --- a/docs/configuration/service/usbip-server.md +++ b/docs/configuration/service/usbip-server.md @@ -26,12 +26,6 @@ requires disabling System Integrity Protection). Not available on iOS. } ``` -!!! info "Difference from the official USB/IP protocol" - - sing-box uses [sing-usbip](https://github.com/SagerNet/sing-usbip), which uses an additional - set of protocols to support enhancements such as hotplug, while remaining interoperable with - the standard USB/IP protocol. - ### Listen Fields See [Listen Fields](/configuration/shared/listen/) for details. diff --git a/docs/configuration/service/usbip-server.zh.md b/docs/configuration/service/usbip-server.zh.md index 52e5bbb7..d35ac675 100644 --- a/docs/configuration/service/usbip-server.zh.md +++ b/docs/configuration/service/usbip-server.zh.md @@ -23,10 +23,6 @@ USB/IP Server 服务通过 [USB/IP](https://usbip.sourceforge.net/) 导出本地 } ``` -!!! info "与官方 USB/IP 协议的区别" - - sing-box 使用 [sing-usbip](https://github.com/SagerNet/sing-usbip),它使用一套附加协议来支持热插拔等增强功能,但仍然可以与标准 USB/IP 互操作。 - ### 监听字段 参阅 [监听字段](/zh/configuration/shared/listen/) 了解详情。 diff --git a/docs/configuration/shared/certificate-provider/acme.zh.md b/docs/configuration/shared/certificate-provider/acme.zh.md index e01986d5..251cfece 100644 --- a/docs/configuration/shared/certificate-provider/acme.zh.md +++ b/docs/configuration/shared/certificate-provider/acme.zh.md @@ -153,5 +153,3 @@ ACME DNS01 质询字段。如果配置,将禁用其他质询方法。 用于所有提供者 HTTP 请求的 HTTP 客户端。 参阅 [HTTP 客户端字段](/zh/configuration/shared/http-client/) 了解详情。 - -所有提供者 HTTP 请求将使用此出站。 diff --git a/docs/configuration/shared/dial.md b/docs/configuration/shared/dial.md index 83f867cb..bd38726c 100644 --- a/docs/configuration/shared/dial.md +++ b/docs/configuration/shared/dial.md @@ -120,7 +120,7 @@ Reuse listener address. Set network namespace, name or path. Since sing-box 1.14.0, the tag of a [network namespace](/configuration/network-namespace/) can also be used. -Referencing an `unshare` network namespace should be avoided, since its only route out is the tun interface managed by sing-box itself. +Referencing an `unshare` network namespace should be avoided. #### connect_timeout diff --git a/docs/configuration/shared/dial.zh.md b/docs/configuration/shared/dial.zh.md index 3067c9d4..3325069d 100644 --- a/docs/configuration/shared/dial.zh.md +++ b/docs/configuration/shared/dial.zh.md @@ -120,7 +120,7 @@ icon: material/new-box 设置网络命名空间,名称或路径。 自 sing-box 1.14.0 起,也可以使用[网络命名空间](/zh/configuration/network-namespace/)的标签。 -应避免引用 `unshare` 类型的网络命名空间,因为其唯一出口是由 sing-box 自身管理的 tun 接口。 +应避免引用 `unshare` 类型的网络命名空间。 #### connect_timeout diff --git a/docs/configuration/shared/http-client.md b/docs/configuration/shared/http-client.md index a0aa9d23..dfa986b8 100644 --- a/docs/configuration/shared/http-client.md +++ b/docs/configuration/shared/http-client.md @@ -42,8 +42,7 @@ Values: !!! warning "" - Experimental only: due to the high memory overhead of both CGO and Network.framework, - do not use in hot paths on iOS and tvOS. + Experimental only: do not use in hot paths on iOS and tvOS. Supported fields: diff --git a/docs/configuration/shared/http-client.zh.md b/docs/configuration/shared/http-client.zh.md index 5c05968a..7b28d4c3 100644 --- a/docs/configuration/shared/http-client.zh.md +++ b/docs/configuration/shared/http-client.zh.md @@ -42,8 +42,7 @@ icon: material/new-box !!! warning "" - 仅供实验用途:由于 CGO 和 Network.framework 占用的内存都很多, - 不应在 iOS 和 tvOS 的热路径中使用。 + 仅供实验用途:不应在 iOS 和 tvOS 的热路径中使用。 支持的字段: diff --git a/docs/configuration/shared/pre-match.md b/docs/configuration/shared/pre-match.md index f88f7676..95d54819 100644 --- a/docs/configuration/shared/pre-match.md +++ b/docs/configuration/shared/pre-match.md @@ -58,8 +58,7 @@ See [route](/configuration/route/rule_action/#route) for details. !!! question "Since sing-box 1.14.0" -For UDP connections, the first packet is available in pre-match, -so protocol sniffing runs on it directly and rule matching continues with the sniffed metadata. +For UDP connections, sniffing runs on the first packet and rule matching continues with the sniffed metadata. When sniffers require more data (like a fragmented QUIC Client Hello), pre-match stops at that rule. diff --git a/docs/configuration/shared/pre-match.zh.md b/docs/configuration/shared/pre-match.zh.md index 9e41d239..a3274494 100644 --- a/docs/configuration/shared/pre-match.zh.md +++ b/docs/configuration/shared/pre-match.zh.md @@ -52,7 +52,7 @@ FakeIP 目标需要在预匹配中先执行 `resolve` 动作,否则连接将 !!! question "自 sing-box 1.14.0 起" -对于 UDP 连接,首个数据包在预匹配中可用,因此协议探测将直接在其上运行,随后规则匹配将携带探测结果继续。 +对于 UDP 连接,协议探测在首个数据包上运行,随后规则匹配将携带探测结果继续。 当探测器需要更多数据时(如分片的 QUIC Client Hello),预匹配将在该规则处停止。 diff --git a/docs/configuration/shared/tls.md b/docs/configuration/shared/tls.md index 0b3adc15..984c6cfe 100644 --- a/docs/configuration/shared/tls.md +++ b/docs/configuration/shared/tls.md @@ -237,33 +237,10 @@ Unsupported fields: !!! note "" - TLS 1.3 is only negotiated on Windows 11 or Windows Server 2022 and newer. On older Windows versions, Schannel caps the connection at TLS 1.2 even when `max_version` is `1.3`. + TLS 1.3 is only negotiated on Windows 11 or Windows Server 2022 and newer. The default version range is TLS 1.2 to TLS 1.3, matching the `go` engine. -Supported fields: - -* `server_name` -* `insecure` -* `alpn` -* `min_version` -* `max_version` -* `certificate` / `certificate_path` -* `certificate_public_key_sha256` -* `handshake_timeout` - -Unsupported fields: - -* `disable_sni` -* `cipher_suites` -* `curve_preferences` -* `client_certificate` / `client_certificate_path` / `client_key` / `client_key_path` -* `fragment` / `record_fragment` -* `kernel_tx` / `kernel_rx` -* `ech` -* `utls` -* `reality` - #### disable_sni ==Client only== @@ -686,16 +663,8 @@ Fragment TLS handshake into multiple TLS records to bypass firewalls. Inject a forged TLS ClientHello carrying a whitelisted SNI before the real one, to fool SNI-filtering middleboxes that permit specific hostnames. -The forged segment is a copy of the real ClientHello with only the SNI value -replaced by the value of this field, so TLS fingerprinting cannot distinguish -it from the real one. The receiving server drops the forged segment -(see `spoof_method`) while the middlebox treats it as a legitimate session. - -Requires raw-socket access (`CAP_NET_RAW` on Linux, root on macOS); -on Linux, `CAP_NET_ADMIN` is additionally required because the send sequence -number is read via `TCP_REPAIR`. -On Windows, Administrator is required to install the embedded WinDivert kernel -driver on first use. Windows on ARM64 is not supported. +Requires `CAP_NET_RAW` and `CAP_NET_ADMIN` on Linux, root on macOS, and +Administrator on Windows. Windows on ARM64 is not supported. #### spoof_method @@ -710,8 +679,8 @@ How the forged segment is rejected by the real server. | `wrong-sequence` (default) | The forged segment's TCP sequence number is placed before the server's receive window. | | `wrong-checksum` | The forged segment's TCP checksum is deliberately invalid. | | `wrong-ack` | The forged segment's TCP acknowledgment number is placed before the server's send window. | -| `wrong-md5` | The forged segment carries a TCP-MD5 signature option, which the server rejects since no MD5 key is negotiated. | -| `wrong-timestamp` | The forged segment carries a backdated TCP timestamp, which the server rejects as a PAWS replay. Linux/Windows only; not supported on macOS. | +| `wrong-md5` | The forged segment carries a TCP-MD5 signature option. | +| `wrong-timestamp` | The forged segment carries a backdated TCP timestamp. Linux/Windows only; not supported on macOS. | ### ACME Fields diff --git a/docs/configuration/shared/tls.zh.md b/docs/configuration/shared/tls.zh.md index 9a86eb34..0db72f43 100644 --- a/docs/configuration/shared/tls.zh.md +++ b/docs/configuration/shared/tls.zh.md @@ -237,32 +237,9 @@ TLS 版本值: !!! note "" - TLS 1.3 仅在 Windows 11 或 Windows Server 2022 及后续版本上协商。在更早的 Windows 版本上,即使 `max_version` 设为 `1.3`,Schannel 也会把连接上限固定在 TLS 1.2。 + TLS 1.3 仅在 Windows 11 或 Windows Server 2022 及后续版本上协商。 -默认版本范围为 TLS 1.2 到 TLS 1.3,与 `go` 引擎一致。证书验证在 Go 侧基于 Schannel 返回的证书链执行,默认使用系统证书存储。当设置了 `certificate` 或 `certificate_path` 时,这些根证书会替代系统存储。 - -支持的字段: - -* `server_name` -* `insecure` -* `alpn` -* `min_version` -* `max_version` -* `certificate` / `certificate_path` -* `certificate_public_key_sha256` -* `handshake_timeout` - -不支持的字段: - -* `disable_sni` -* `cipher_suites` -* `curve_preferences` -* `client_certificate` / `client_certificate_path` / `client_key` / `client_key_path` -* `fragment` / `record_fragment` -* `kernel_tx` / `kernel_rx` -* `ech` -* `utls` -* `reality` +默认版本范围为 TLS 1.2 到 TLS 1.3,与 `go` 引擎一致。 #### disable_sni @@ -682,13 +659,7 @@ ECH 配置路径,PEM 格式。 在真实 ClientHello 之前注入一个伪造的、携带白名单 SNI 的 TLS ClientHello, 以欺骗基于 SNI 过滤的中间盒放行连接。 -伪造报文是真实 ClientHello 的副本,仅将 SNI 值替换为本字段的值, -因此 TLS 指纹无法区分伪造与真实报文。真实服务器会丢弃伪造报文(见 `spoof_method`), -而中间盒将该连接视为合法会话。 - -需要原始套接字权限(Linux 上需 `CAP_NET_RAW`,macOS 上需 root); -在 Linux 上还需 `CAP_NET_ADMIN`,因为需要通过 `TCP_REPAIR` 读取发送序列号。 -Windows 上首次使用时需要 Administrator 以安装内嵌的 WinDivert 内核驱动, +Linux 上需要 `CAP_NET_RAW` 和 `CAP_NET_ADMIN`,macOS 上需要 root,Windows 上需要 Administrator。 不支持 Windows ARM64。 #### spoof_method @@ -704,8 +675,8 @@ Windows 上首次使用时需要 Administrator 以安装内嵌的 WinDivert 内 | `wrong-sequence`(默认) | 伪造报文的 TCP 序列号位于服务器接收窗口之前。 | | `wrong-checksum` | 伪造报文的 TCP 校验和被故意设为无效。 | | `wrong-ack` | 伪造报文的 TCP 确认号位于服务器发送窗口之前。 | -| `wrong-md5` | 伪造报文携带 TCP-MD5 签名选项,未协商 MD5 密钥的服务器将拒绝。 | -| `wrong-timestamp` | 伪造报文携带回退的 TCP 时间戳,服务器按 PAWS 规则视为重放并拒绝。仅支持 Linux/Windows,不支持 macOS。 | +| `wrong-md5` | 伪造报文携带 TCP-MD5 签名选项。 | +| `wrong-timestamp` | 伪造报文携带回退的 TCP 时间戳。仅支持 Linux/Windows,不支持 macOS。 | ### ACME 字段 diff --git a/docs/deprecated.md b/docs/deprecated.md index 8e8498ae..96470afd 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -43,8 +43,7 @@ Old fields will be removed in sing-box 1.16.0. #### `independent_cache` DNS option -`independent_cache` DNS option is deprecated. -The DNS cache now always keys by transport, making this option unnecessary, +`independent_cache` DNS option is deprecated, check [Migration](../migration/#migrate-independent-dns-cache). Old fields will be removed in sing-box 1.16.0. diff --git a/docs/deprecated.zh.md b/docs/deprecated.zh.md index 887139bb..2b96258c 100644 --- a/docs/deprecated.zh.md +++ b/docs/deprecated.zh.md @@ -43,8 +43,7 @@ TLS 中的内联 ACME 选项(`tls.acme`)已废弃, #### `independent_cache` DNS 选项 -`independent_cache` DNS 选项已废弃。 -DNS 缓存现在始终按传输分离,使此选项不再需要, +`independent_cache` DNS 选项已废弃, 参阅[迁移指南](/zh/migration/#迁移-independent-dns-cache)。 旧字段将在 sing-box 1.16.0 中被移除。 diff --git a/docs/installation/build-from-source.md b/docs/installation/build-from-source.md index ea799863..413b7726 100644 --- a/docs/installation/build-from-source.md +++ b/docs/installation/build-from-source.md @@ -62,8 +62,8 @@ go build -tags "tag_a tag_b" ./cmd/sing-box | `with_ocm` | :material-check: | Build with OpenAI Codex Multiplexer service support. | | `with_naive_outbound` | :material-check: | Build with NaiveProxy outbound support, see [NaiveProxy outbound](/configuration/outbound/naive/). | | `with_cloudflared` | :material-check: | Build with Cloudflare Tunnel inbound support, see [Cloudflared inbound](/configuration/inbound/cloudflared/). | -| `badlinkname` | :material-check: | Enable `go:linkname` access to internal standard library functions. Required because the Go standard library does not expose many low-level APIs needed by this project, and reimplementing them externally is impractical. Used for kTLS (kernel TLS offload) and raw TLS record manipulation. | -| `tfogo_checklinkname0` | :material-check: | Companion to `badlinkname`. Go 1.23+ enforces `go:linkname` restrictions via the linker; this tag signals the build uses `-checklinkname=0` to bypass that enforcement. | +| `badlinkname` | :material-check: | Enable `go:linkname` access to internal standard library functions. | +| `tfogo_checklinkname0` | :material-check: | Indicates the build uses the `-checklinkname=0` linker flag. Required together with `badlinkname`. | It is not recommended to change the default build tag list unless you really know what you are adding. @@ -73,7 +73,7 @@ The required linker flags for official builds are maintained in `release/LDFLAGS | Flag | Description | |-------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `-checklinkname=0` | Go 1.23+ linker rejects unauthorized `go:linkname` usage. This flag disables the check, required together with the `badlinkname` build tag. | +| `-checklinkname=0` | Required together with the `badlinkname` build tag. | ## :material-package-variant: For Downstream Packagers diff --git a/docs/installation/build-from-source.zh.md b/docs/installation/build-from-source.zh.md index cfd8ac76..c99a714c 100644 --- a/docs/installation/build-from-source.zh.md +++ b/docs/installation/build-from-source.zh.md @@ -66,8 +66,8 @@ go build -tags "tag_a tag_b" ./cmd/sing-box | `with_ocm` | :material-check: | 构建 OpenAI Codex Multiplexer 服务支持。 | | `with_naive_outbound` | :material-check: | 构建 NaiveProxy 出站支持,参阅 [NaiveProxy 出站](/zh/configuration/outbound/naive/)。 | | `with_cloudflared` | :material-check: | 构建 Cloudflare Tunnel 入站支持,参阅 [Cloudflared 入站](/zh/configuration/inbound/cloudflared/)。 | -| `badlinkname` | :material-check: | 启用 `go:linkname` 以访问标准库内部函数。Go 标准库未提供本项目需要的许多底层 API,且在外部重新实现不切实际。用于 kTLS(内核 TLS 卸载)和原始 TLS 记录操作。 | -| `tfogo_checklinkname0` | :material-check: | `badlinkname` 的伴随标记。Go 1.23+ 链接器强制限制 `go:linkname` 使用;此标记表示构建使用 `-checklinkname=0` 以绕过该限制。 | +| `badlinkname` | :material-check: | 启用 `go:linkname` 以访问标准库内部函数。 | +| `tfogo_checklinkname0` | :material-check: | 表示构建使用 `-checklinkname=0` 链接器标志,需要与 `badlinkname` 一起使用。 | 除非您确实知道您正在启用什么,否则不建议更改默认构建标签列表。 @@ -77,7 +77,7 @@ go build -tags "tag_a tag_b" ./cmd/sing-box | 标志 | 说明 | |-------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `-checklinkname=0` | Go 1.23+ 链接器拒绝未授权的 `go:linkname` 使用。此标志禁用该检查,需要与 `badlinkname` 构建标记一起使用。 | +| `-checklinkname=0` | 需要与 `badlinkname` 构建标记一起使用。 | ## :material-package-variant: 下游打包者 diff --git a/docs/migration.md b/docs/migration.md index 8a78869f..7e718943 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -107,7 +107,8 @@ See [ACME](/configuration/shared/certificate-provider/acme/) for fields newly ad Legacy Address Filter Fields (`ip_cidr`, `ip_is_private` without `match_response`) in DNS rules are deprecated, along with the Legacy `rule_set_ip_cidr_accept_empty` DNS rule item. A DNS rule that references a rule-set containing only `ip_cidr` items (for example, a GeoIP rule-set) without `match_response` is also rejected -at startup when legacy DNS mode is disabled. +at startup when DNS rules use features added in sing-box 1.14.0, such as `evaluate`, `match_response` +or `ip_version`. In sing-box 1.14.0, use the [`evaluate`](/configuration/dns/rule_action/#evaluate) action to fetch a DNS response, then match against it explicitly with `match_response`. @@ -164,8 +165,7 @@ to fetch a DNS response, then match against it explicitly with `match_response`. ### Migrate independent DNS cache -The DNS cache now always keys by transport name, making `independent_cache` unnecessary. -Simply remove the field. +`independent_cache` is deprecated, remove the field. !!! info "References" @@ -232,31 +232,10 @@ In sing-box 1.14.0, the behavior of [`query_type`](/configuration/rule-set/headless-rule/#query_type) in referenced rule-sets, changes in two ways. -First, these fields now take effect on every DNS rule evaluation. In earlier -versions they were evaluated only for DNS queries received from a client -(for example, from a DNS inbound or intercepted by `tun`), and were silently -ignored when a DNS rule was matched from an internal domain resolution that -did not target a specific DNS server. Such internal resolutions include: - -- The [`resolve`](/configuration/route/rule_action/#resolve) route rule - action without a `server` set. -- ICMP traffic routed to a domain destination through a `direct` outbound. -- A [WireGuard](/configuration/endpoint/wireguard/) or - [Tailscale](/configuration/endpoint/tailscale/) endpoint used as an - outbound, when resolving its own destination address. -- A [SOCKS4](/configuration/outbound/socks/) outbound, which must resolve - the destination locally because the protocol has no in-protocol domain - support. -- The [DERP](/configuration/service/derp/) `bootstrap-dns` endpoint and the - [`resolved`](/configuration/service/resolved/) service (when resolving a - hostname or an SRV target). - -Resolutions that target a specific DNS server — via -[`domain_resolver`](/configuration/shared/dial/#domain_resolver) on a dial -field, [`default_domain_resolver`](/configuration/route/#default_domain_resolver) -in route options, or an explicit `server` on a DNS rule action or the -`resolve` route rule action — do not go through DNS rule matching and are -unaffected. +First, these fields now also take effect when a DNS rule is matched from an +internal domain resolution that does not target a specific DNS server, such as +the [`resolve`](/configuration/route/rule_action/#resolve) route rule action +without a `server` set. Second, setting `ip_version` or `query_type` in a DNS rule, or referencing a rule-set containing `query_type`, is no longer compatible in the same DNS diff --git a/docs/migration.zh.md b/docs/migration.zh.md index a710b17c..198f600b 100644 --- a/docs/migration.zh.md +++ b/docs/migration.zh.md @@ -104,7 +104,8 @@ sing-box 1.14.0 新增字段参阅 [ACME](/zh/configuration/shared/certificate-p ### 迁移地址筛选字段到响应匹配 旧版地址筛选字段(不使用 `match_response` 的 `ip_cidr`、`ip_is_private`)已废弃, -旧版 `rule_set_ip_cidr_accept_empty` DNS 规则项也已废弃。当旧版 DNS 模式被禁用时, +旧版 `rule_set_ip_cidr_accept_empty` DNS 规则项也已废弃。当 DNS 规则使用了 sing-box 1.14.0 +新增的功能(例如 `evaluate`、`match_response` 或 `ip_version`)时, 引用仅包含 `ip_cidr` 项的规则集(例如 GeoIP 规则集)且未设置 `match_response` 的 DNS 规则 也将在启动时被拒绝。 @@ -163,8 +164,7 @@ sing-box 1.14.0 新增字段参阅 [ACME](/zh/configuration/shared/certificate-p ### 迁移 independent DNS cache -DNS 缓存现在始终按传输名称分离,使 `independent_cache` 不再需要。 -直接移除该字段即可。 +`independent_cache` 已废弃,直接移除该字段即可。 !!! info "参考" @@ -231,24 +231,8 @@ DNS 缓存现在始终按传输名称分离,使 `independent_cache` 不再需 [`query_type`](/zh/configuration/rule-set/headless-rule/#query_type), 行为有两项更改。 -其一,这些字段现在对每一次 DNS 规则评估都会生效。此前它们仅对来自客户端的 DNS 查询 -(例如来自 DNS 入站或被 `tun` 截获的查询)生效,当 DNS 规则被未指定具体 DNS 服务器的 -内部域名解析匹配时,会被静默忽略。此类内部解析包括: - -- 未设置 `server` 的 [`resolve`](/zh/configuration/route/rule_action/#resolve) 路由规则动作。 -- 通过 `direct` 出站路由到域名目标的 ICMP 流量。 -- 作为出站使用的 [WireGuard](/zh/configuration/endpoint/wireguard/) 或 - [Tailscale](/zh/configuration/endpoint/tailscale/) 端点在解析自身目标地址时。 -- [SOCKS4](/zh/configuration/outbound/socks/) 出站,因为协议本身不支持域名, - 必须在本地解析目标。 -- [DERP](/zh/configuration/service/derp/) 的 `bootstrap-dns` 端点,以及 - [`resolved`](/zh/configuration/service/resolved/) 服务在解析主机名或 SRV 目标时。 - -通过拨号字段中的 -[`domain_resolver`](/zh/configuration/shared/dial/#domain_resolver)、 -路由选项中的 [`default_domain_resolver`](/zh/configuration/route/#default_domain_resolver), -或 DNS 规则动作与 `resolve` 路由规则动作上显式的 `server` 指定具体 DNS 服务器的 -解析,不会经过 DNS 规则匹配,不受此次更改影响。 +其一,当 DNS 规则被未指定具体 DNS 服务器的内部域名解析匹配时,这些字段现在也会生效, +例如未设置 `server` 的 [`resolve`](/zh/configuration/route/rule_action/#resolve) 路由规则动作。 其二,设置了 `ip_version` 或 `query_type` 的 DNS 规则,或引用了包含 `query_type` 的 规则集的 DNS 规则,在同一 DNS 配置中不再能与旧版地址筛选字段 (DNS 规则)、旧版 From 0efa6449429b4ffa1fd1eafa6920d264884f7966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 27 Sep 2026 17:16:25 +0800 Subject: [PATCH 40/66] Fix tun GSO check --- protocol/openconnect/endpoint.go | 5 ----- protocol/openvpn/endpoint.go | 4 ---- protocol/tun/inbound.go | 4 ++-- 3 files changed, 2 insertions(+), 11 deletions(-) diff --git a/protocol/openconnect/endpoint.go b/protocol/openconnect/endpoint.go index 21999365..b7915c61 100644 --- a/protocol/openconnect/endpoint.go +++ b/protocol/openconnect/endpoint.go @@ -4,7 +4,6 @@ import ( "context" "net" "net/netip" - "slices" "strings" "github.com/sagernet/sing-box/adapter" @@ -33,10 +32,6 @@ type endpointBase struct { logger log.ContextLogger } -func (e *endpointBase) SupportsFlow(network string) bool { - return slices.Contains(e.Network(), network) -} - func (e *endpointBase) newConnection(ctx context.Context, endpoint adapter.Endpoint, localAddresses []netip.Prefix, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { var metadata adapter.InboundContext metadata.Inbound = endpoint.Tag() diff --git a/protocol/openvpn/endpoint.go b/protocol/openvpn/endpoint.go index 0aa7ff67..33cd43a1 100644 --- a/protocol/openvpn/endpoint.go +++ b/protocol/openvpn/endpoint.go @@ -38,10 +38,6 @@ type endpointBase struct { logger log.ContextLogger } -func (e *endpointBase) SupportsFlow(network string) bool { - return slices.Contains(e.Network(), network) -} - func (e *endpointBase) newConnection(ctx context.Context, endpoint adapter.Endpoint, localAddresses []netip.Prefix, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { var metadata adapter.InboundContext metadata.Inbound = endpoint.Tag() diff --git a/protocol/tun/inbound.go b/protocol/tun/inbound.go index 22172e50..719edd81 100644 --- a/protocol/tun/inbound.go +++ b/protocol/tun/inbound.go @@ -342,7 +342,7 @@ func (t *Inbound) Start(stage adapter.StartStage) error { outboundManager := service.FromContext[adapter.OutboundManager](t.ctx) endpointManager := service.FromContext[adapter.EndpointManager](t.ctx) for _, outbound := range outboundManager.Outbounds() { - if _, isFlowOutbound := outbound.(adapter.FlowOutbound); isFlowOutbound && common.Contains(outbound.Network(), N.NetworkTCP) { + if flowOutbound, isFlowOutbound := outbound.(adapter.FlowOutbound); isFlowOutbound && flowOutbound.PreMatchFlow(N.NetworkTCP, netip.Addr{}) == adapter.PreMatchFlow { if C.IsLinux { t.tunOptions.GSO = true } else { @@ -352,7 +352,7 @@ func (t *Inbound) Start(stage adapter.StartStage) error { } } for _, endpoint := range endpointManager.Endpoints() { - if _, isFlowOutbound := endpoint.(adapter.FlowOutbound); isFlowOutbound && common.Contains(endpoint.Network(), N.NetworkTCP) { + if flowOutbound, isFlowOutbound := endpoint.(adapter.FlowOutbound); isFlowOutbound && flowOutbound.PreMatchFlow(N.NetworkTCP, netip.Addr{}) == adapter.PreMatchFlow { if C.IsLinux { t.tunOptions.GSO = true } else { From d41e1745e6d0fabdc8083556f98309461535f622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 27 Sep 2026 17:16:25 +0800 Subject: [PATCH 41/66] Fix UDP fragmentation --- common/dialer/default.go | 5 ++++- common/listener/listener_udp.go | 4 +++- go.mod | 2 +- go.sum | 4 ++-- protocol/hysteria/inbound.go | 1 - protocol/hysteria/outbound.go | 1 - protocol/hysteria2/inbound.go | 1 - protocol/hysteria2/outbound.go | 1 - protocol/tuic/inbound.go | 1 - protocol/tuic/outbound.go | 1 - 10 files changed, 10 insertions(+), 11 deletions(-) diff --git a/common/dialer/default.go b/common/dialer/default.go index 0bffbf1c..a48f675d 100644 --- a/common/dialer/default.go +++ b/common/dialer/default.go @@ -188,7 +188,10 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial } else { udpFragment = options.UDPFragmentDefault } - if !udpFragment { + if udpFragment { + dialer.Control = control.Append(dialer.Control, control.EnableUDPFragment()) + listener.Control = control.Append(listener.Control, control.EnableUDPFragment()) + } else { dialer.Control = control.Append(dialer.Control, control.DisableUDPFragment()) listener.Control = control.Append(listener.Control, control.DisableUDPFragment()) } diff --git a/common/listener/listener_udp.go b/common/listener/listener_udp.go index fab87cbc..697ef34d 100644 --- a/common/listener/listener_udp.go +++ b/common/listener/listener_udp.go @@ -42,7 +42,9 @@ func (l *Listener) ListenUDPWithConfig(listenConfig net.ListenConfig) (net.Packe } else { udpFragment = l.listenOptions.UDPFragmentDefault } - if !udpFragment { + if udpFragment { + listenConfig.Control = control.Append(listenConfig.Control, control.EnableUDPFragment()) + } else { listenConfig.Control = control.Append(listenConfig.Control, control.DisableUDPFragment()) } if l.tproxy { diff --git a/go.mod b/go.mod index b2ab87ca..69ab9c02 100644 --- a/go.mod +++ b/go.mod @@ -45,7 +45,7 @@ require ( github.com/sagernet/netlink v0.0.0-20260814022025-64455d367bbf github.com/sagernet/nftables v0.3.0-mod.4 github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 - github.com/sagernet/sing v0.9.6-0.20260922013354-87c33f17688f + github.com/sagernet/sing v0.9.6-0.20260927091426-5f9aad7def20 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.8 github.com/sagernet/sing-openconnect v0.0.0-20260810065514-53aa8058f8df diff --git a/go.sum b/go.sum index 3172be80..76c18a0b 100644 --- a/go.sum +++ b/go.sum @@ -318,8 +318,8 @@ github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4IC github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 h1:sW2O+DoNF+my1PGlSZ3zG2N+fdxLR3DC0l4sXCM0lBE= github.com/sagernet/quic-go v0.61.0-sing-box-mod.7/go.mod h1:hmLC8GJPp+BrpBgrgJnacvI4fZGIhasuy1ROJJ8GR4E= -github.com/sagernet/sing v0.9.6-0.20260922013354-87c33f17688f h1:t2lyXQFJmCPsybxKqYE8YlfDIvxK9Z343HOvAX7pIKA= -github.com/sagernet/sing v0.9.6-0.20260922013354-87c33f17688f/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= +github.com/sagernet/sing v0.9.6-0.20260927091426-5f9aad7def20 h1:FRiFCD9vYplVTPrHJBlGmq1MhL9T3Eylee2b7yV7+l0= +github.com/sagernet/sing v0.9.6-0.20260927091426-5f9aad7def20/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6++yIa8XlDhxPkpR4p+7RUHVY2KTP9CPIGnWmOlO8= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.8 h1:mM0TLUkr36aEhgUHi9Qh60qcAbD2fZcmnJ30937xxrY= diff --git a/protocol/hysteria/inbound.go b/protocol/hysteria/inbound.go index 6fd4fe97..a0f86c43 100644 --- a/protocol/hysteria/inbound.go +++ b/protocol/hysteria/inbound.go @@ -34,7 +34,6 @@ type Inbound struct { } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.HysteriaInboundOptions) (adapter.Inbound, error) { - options.UDPFragmentDefault = true if options.TLS == nil || !options.TLS.Enabled { return nil, C.ErrTLSRequired } diff --git a/protocol/hysteria/outbound.go b/protocol/hysteria/outbound.go index a9451086..937d55db 100644 --- a/protocol/hysteria/outbound.go +++ b/protocol/hysteria/outbound.go @@ -39,7 +39,6 @@ type Outbound struct { } func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.HysteriaOutboundOptions) (adapter.Outbound, error) { - options.UDPFragmentDefault = true if options.TLS == nil || !options.TLS.Enabled { return nil, C.ErrTLSRequired } diff --git a/protocol/hysteria2/inbound.go b/protocol/hysteria2/inbound.go index 977b5a9c..f86e2402 100644 --- a/protocol/hysteria2/inbound.go +++ b/protocol/hysteria2/inbound.go @@ -46,7 +46,6 @@ type Inbound struct { } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.Hysteria2InboundOptions) (adapter.Inbound, error) { - options.UDPFragmentDefault = true if options.TLS == nil || !options.TLS.Enabled { return nil, C.ErrTLSRequired } diff --git a/protocol/hysteria2/outbound.go b/protocol/hysteria2/outbound.go index e235a472..392b0e40 100644 --- a/protocol/hysteria2/outbound.go +++ b/protocol/hysteria2/outbound.go @@ -46,7 +46,6 @@ type Outbound struct { } func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.Hysteria2OutboundOptions) (adapter.Outbound, error) { - options.UDPFragmentDefault = true if options.TLS == nil || !options.TLS.Enabled { return nil, C.ErrTLSRequired } diff --git a/protocol/tuic/inbound.go b/protocol/tuic/inbound.go index 531d4263..11c38efb 100644 --- a/protocol/tuic/inbound.go +++ b/protocol/tuic/inbound.go @@ -39,7 +39,6 @@ type Inbound struct { } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TUICInboundOptions) (adapter.Inbound, error) { - options.UDPFragmentDefault = true if options.TLS == nil || !options.TLS.Enabled { return nil, C.ErrTLSRequired } diff --git a/protocol/tuic/outbound.go b/protocol/tuic/outbound.go index 7c8ce053..53f65ccb 100644 --- a/protocol/tuic/outbound.go +++ b/protocol/tuic/outbound.go @@ -40,7 +40,6 @@ type Outbound struct { } func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TUICOutboundOptions) (adapter.Outbound, error) { - options.UDPFragmentDefault = true if options.TLS == nil || !options.TLS.Enabled { return nil, C.ErrTLSRequired } From d2cf9747d7689b4b4a3e199a1bb9bacd0fecf768 Mon Sep 17 00:00:00 2001 From: embogomolov Date: Sun, 27 Sep 2026 18:40:38 +0800 Subject: [PATCH 42/66] Fix interrupt group holding lock while closing connections --- common/interrupt/conn.go | 4 ++-- common/interrupt/group.go | 16 +++++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/common/interrupt/conn.go b/common/interrupt/conn.go index fa0b7655..2b30d85b 100644 --- a/common/interrupt/conn.go +++ b/common/interrupt/conn.go @@ -16,8 +16,8 @@ type Conn struct { func (c *Conn) Close() error { c.group.access.Lock() - defer c.group.access.Unlock() c.group.connections.Remove(c.element) + c.group.access.Unlock() return c.Conn.Close() } @@ -45,8 +45,8 @@ func newPacketConn(group *Group, conn net.PacketConn, element *list.Element[*gro func (c *PacketConn) Close() error { c.group.access.Lock() - defer c.group.access.Unlock() c.group.connections.Remove(c.element) + c.group.access.Unlock() return c.NetPacketConn.Close() } diff --git a/common/interrupt/group.go b/common/interrupt/group.go index 63ae199e..f0198033 100644 --- a/common/interrupt/group.go +++ b/common/interrupt/group.go @@ -38,15 +38,17 @@ func (g *Group) NewPacketConn(conn net.PacketConn, isExternal bool) net.PacketCo func (g *Group) Interrupt(interruptExternalConnections bool) { g.access.Lock() - defer g.access.Unlock() - var toDelete []*list.Element[*groupConnItem] - for element := g.connections.Front(); element != nil; element = element.Next() { + var closers []io.Closer + for element := g.connections.Front(); element != nil; { + nextElement := element.Next() if !element.Value.isExternal || interruptExternalConnections { - element.Value.conn.Close() - toDelete = append(toDelete, element) + closers = append(closers, element.Value.conn) + g.connections.Remove(element) } + element = nextElement } - for _, element := range toDelete { - g.connections.Remove(element) + g.access.Unlock() + for _, closer := range closers { + closer.Close() } } From fd9a076d252b6e868383bc23bc1a4fe44ce981f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 2 Sep 2026 22:28:27 +0800 Subject: [PATCH 43/66] Close idle connections of unreferenced outbounds and DNS servers --- adapter/outbound.go | 8 + adapter/outbound/manager.go | 6 +- box.go | 1 + common/httpclient/client.go | 22 ++- common/httpclient/managed_transport.go | 10 +- common/httpclient/manager.go | 26 +++ dns/transport_adapter.go | 15 ++ experimental/boxdd/cmd_run.go | 2 +- experimental/boxdd/server.go | 4 +- experimental/libbox/power_report.go | 4 +- go.mod | 6 +- go.sum | 12 +- protocol/cloudflare/inbound.go | 13 +- protocol/group/selector.go | 5 + protocol/group/urltest.go | 26 ++- protocol/hysteria/outbound.go | 5 + protocol/hysteria2/outbound.go | 5 + protocol/shadowsocks/outbound.go | 12 +- protocol/shadowtls/inbound.go | 19 +- protocol/snell/outbound.go | 10 +- protocol/ssh/outbound.go | 68 ++++++- protocol/tailscale/endpoint.go | 10 ++ protocol/trojan/inbound.go | 8 + protocol/trojan/outbound.go | 16 +- protocol/tuic/outbound.go | 9 +- protocol/vless/inbound.go | 24 ++- protocol/vless/outbound.go | 16 +- protocol/vmess/outbound.go | 16 +- route/reference.go | 235 +++++++++++++++++++++++++ route/reference_rule.go | 168 ++++++++++++++++++ service/ccm/service.go | 9 + service/derp/service.go | 6 + service/ocm/service.go | 9 + service/powerreport/promote.go | 9 +- service/powerreport/recorder.go | 2 +- service/usbip/client.go | 9 + transport/v2raygrpclite/client.go | 4 + transport/v2rayhttp/client.go | 4 + transport/v2rayquic/client.go | 93 ++++++++-- transport/v2rayquic/stream.go | 6 + 40 files changed, 851 insertions(+), 81 deletions(-) create mode 100644 route/reference.go create mode 100644 route/reference_rule.go diff --git a/adapter/outbound.go b/adapter/outbound.go index 3fafee51..f688f164 100644 --- a/adapter/outbound.go +++ b/adapter/outbound.go @@ -50,3 +50,11 @@ type OutboundManager interface { Remove(tag string) error Create(ctx context.Context, router Router, logger log.ContextLogger, tag string, outboundType string, options any) error } + +type IdleConnectionCloser interface { + CloseIdleConnections() +} + +type Referrer interface { + References() []string +} diff --git a/adapter/outbound/manager.go b/adapter/outbound/manager.go index 52c5b6aa..5314549d 100644 --- a/adapter/outbound/manager.go +++ b/adapter/outbound/manager.go @@ -151,9 +151,9 @@ func (m *Manager) startOutbounds(outbounds []adapter.Outbound) error { if common.Contains(oTree, problemOutboundTag) { return E.New("circular outbound dependency: ", strings.Join(oTree, " -> "), " -> ", problemOutboundTag) } - m.access.Lock() - problemOutbound := m.outboundByTag[problemOutboundTag] - m.access.Unlock() + problemOutbound := common.Find(outbounds, func(it adapter.Outbound) bool { + return it.Tag() == problemOutboundTag + }) if problemOutbound == nil { return E.New("dependency[", problemOutboundTag, "] not found for outbound[", oCurrent.Tag(), "]") } diff --git a/box.go b/box.go index 92cd1ecd..35037766 100644 --- a/box.go +++ b/box.go @@ -257,6 +257,7 @@ func New(options Options) (*Box, error) { service.MustRegisterPtr(ctx, clashMode) internalServices = append(internalServices, clashMode) } + internalServices = append(internalServices, route.NewReferenceManager(ctx, logFactory.NewLogger("reference"), options.Options)) ntpOptions := common.PtrValueOrDefault(options.NTP) var timeService *tls.TimeServiceWrapper if ntpOptions.Enabled { diff --git a/common/httpclient/client.go b/common/httpclient/client.go index b310152a..f5a56162 100644 --- a/common/httpclient/client.go +++ b/common/httpclient/client.go @@ -40,10 +40,12 @@ func NewTransport(ctx context.Context, logger logger.ContextLogger, tag string, return nil, err } return &ManagedTransport{ - dialer: rawDialer, - headers: headers, - host: host, - tag: tag, + dialer: rawDialer, + headers: headers, + host: host, + tag: tag, + detour: options.Detour, + defaultOutbound: options.DefaultOutbound, factory: func() (innerTransport, error) { return newAppleTransport(ctx, logger, rawDialer, options) }, @@ -69,11 +71,13 @@ func NewTransport(ctx context.Context, logger logger.ContextLogger, tag string, return nil, err } managedTransport := &ManagedTransport{ - cheapRebuild: cheapRebuild, - dialer: rawDialer, - headers: headers, - host: host, - tag: tag, + cheapRebuild: cheapRebuild, + dialer: rawDialer, + headers: headers, + host: host, + tag: tag, + detour: options.Detour, + defaultOutbound: options.DefaultOutbound, factory: func() (innerTransport, error) { return newTransport(rawDialer, baseTLSConfig, options) }, diff --git a/common/httpclient/managed_transport.go b/common/httpclient/managed_transport.go index 779eccda..7e2f9472 100644 --- a/common/httpclient/managed_transport.go +++ b/common/httpclient/managed_transport.go @@ -25,10 +25,12 @@ type ManagedTransport struct { factory func() (innerTransport, error) cheapRebuild bool - dialer N.Dialer - headers http.Header - host string - tag string + dialer N.Dialer + headers http.Header + host string + tag string + detour string + defaultOutbound bool } type transportEpoch struct { diff --git a/common/httpclient/manager.go b/common/httpclient/manager.go index 614e4f83..45e05f21 100644 --- a/common/httpclient/manager.go +++ b/common/httpclient/manager.go @@ -9,11 +9,13 @@ import ( "github.com/sagernet/sing-box/option" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/service" ) var ( _ adapter.HTTPClientManager = (*Manager)(nil) _ adapter.LifecycleService = (*Manager)(nil) + _ adapter.Referrer = (*Manager)(nil) ) type Manager struct { @@ -177,3 +179,27 @@ func (m *Manager) Close() error { m.sharedTransports = nil return err } + +func (m *Manager) References() []string { + m.access.Lock() + defer m.access.Unlock() + var references []string + var needDefault bool + for _, transport := range m.managedTransports { + if transport.detour != "" { + references = append(references, transport.detour) + } else if transport.defaultOutbound { + needDefault = true + } + } + if needDefault { + outboundManager := service.FromContext[adapter.OutboundManager](m.ctx) + if outboundManager != nil { + defaultOutbound := outboundManager.Default() + if defaultOutbound != nil { + references = append(references, defaultOutbound.Tag()) + } + } + } + return references +} diff --git a/dns/transport_adapter.go b/dns/transport_adapter.go index 1e6620f2..7d2ebfa7 100644 --- a/dns/transport_adapter.go +++ b/dns/transport_adapter.go @@ -8,6 +8,7 @@ type TransportAdapter struct { transportType string transportTag string dependencies []string + references []string } func NewTransportAdapter(transportType string, transportTag string, dependencies []string) TransportAdapter { @@ -23,10 +24,15 @@ func NewTransportAdapterWithLocalOptions(transportType string, transportTag stri if localOptions.DomainResolver != nil && localOptions.DomainResolver.Server != "" { dependencies = append(dependencies, localOptions.DomainResolver.Server) } + var references []string + if localOptions.Detour != "" { + references = []string{localOptions.Detour} + } return TransportAdapter{ transportType: transportType, transportTag: transportTag, dependencies: dependencies, + references: references, } } @@ -35,10 +41,15 @@ func NewTransportAdapterWithRemoteOptions(transportType string, transportTag str if remoteOptions.DomainResolver != nil && remoteOptions.DomainResolver.Server != "" { dependencies = append(dependencies, remoteOptions.DomainResolver.Server) } + var references []string + if remoteOptions.Detour != "" { + references = []string{remoteOptions.Detour} + } return TransportAdapter{ transportType: transportType, transportTag: transportTag, dependencies: dependencies, + references: references, } } @@ -53,3 +64,7 @@ func (a *TransportAdapter) Tag() string { func (a *TransportAdapter) Dependencies() []string { return a.dependencies } + +func (a *TransportAdapter) References() []string { + return a.references +} diff --git a/experimental/boxdd/cmd_run.go b/experimental/boxdd/cmd_run.go index be5452b8..f5518bad 100644 --- a/experimental/boxdd/cmd_run.go +++ b/experimental/boxdd/cmd_run.go @@ -67,7 +67,7 @@ func prepareWorkingDirectory() error { return err } libbox.PromoteOOMDraft() - libbox.PromotePowerReportDraft() + libbox.DiscardPowerReportDraft() return nil } diff --git a/experimental/boxdd/server.go b/experimental/boxdd/server.go index 8e9f6b16..ad9df8a2 100644 --- a/experimental/boxdd/server.go +++ b/experimental/boxdd/server.go @@ -203,7 +203,7 @@ func (d *Daemon) configureWorkingDirectoryLocked(directory string) error { return err } libbox.PromoteOOMDraft() - libbox.PromotePowerReportDraft() + libbox.DiscardPowerReportDraft() d.runtimeWorkingDirectory = directory return nil } @@ -262,7 +262,7 @@ func (d *Daemon) stopServiceLocked(ownerUserID string) error { } } d.powerManager.Close() - libbox.PromotePowerReportDraft() + libbox.DiscardPowerReportDraft() directory := userWorkingDirectory(ownerUserID) crashReportError := tagUnownedReports(filepath.Join(directory, crashReportsDirectoryName), ownerUserID) if crashReportError != nil { diff --git a/experimental/libbox/power_report.go b/experimental/libbox/power_report.go index 29648cf6..a31afd2d 100644 --- a/experimental/libbox/power_report.go +++ b/experimental/libbox/power_report.go @@ -36,6 +36,6 @@ func PowerReportOptions(startedService *daemon.StartedService) powerreport.Optio } } -func PromotePowerReportDraft() { - powerreport.PromoteDraft(sWorkingPath) +func DiscardPowerReportDraft() { + powerreport.DiscardDraft(sWorkingPath) } diff --git a/go.mod b/go.mod index 69ab9c02..05bd89dc 100644 --- a/go.mod +++ b/go.mod @@ -47,14 +47,14 @@ require ( github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 github.com/sagernet/sing v0.9.6-0.20260927091426-5f9aad7def20 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 - github.com/sagernet/sing-mux v0.3.8 + github.com/sagernet/sing-mux v0.3.9-0.20260927144857-0257b84c582a github.com/sagernet/sing-openconnect v0.0.0-20260810065514-53aa8058f8df github.com/sagernet/sing-openvpn v0.0.0-20260729104525-103eb5fe5eb6 - github.com/sagernet/sing-quic v0.7.1-0.20260924092235-6a3a24d65b99 + github.com/sagernet/sing-quic v0.7.2-0.20261002084117-75c3ac4fa12b github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1 - github.com/sagernet/sing-snell v0.0.0-20260829071736-20f2eaec77c3 + github.com/sagernet/sing-snell v0.0.0-20260904135315-bc5a12ac736f github.com/sagernet/sing-tun v0.9.6-0.20260924001923-ddaa4ca25e3b github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca github.com/sagernet/sing-vmess v0.2.8 diff --git a/go.sum b/go.sum index 76c18a0b..0dfadd2d 100644 --- a/go.sum +++ b/go.sum @@ -322,22 +322,22 @@ github.com/sagernet/sing v0.9.6-0.20260927091426-5f9aad7def20 h1:FRiFCD9vYplVTPr github.com/sagernet/sing v0.9.6-0.20260927091426-5f9aad7def20/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6++yIa8XlDhxPkpR4p+7RUHVY2KTP9CPIGnWmOlO8= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= -github.com/sagernet/sing-mux v0.3.8 h1:mM0TLUkr36aEhgUHi9Qh60qcAbD2fZcmnJ30937xxrY= -github.com/sagernet/sing-mux v0.3.8/go.mod h1:jricpwCOndDsTEM1TM3piJvhL/2Mm7OhLkm+JadJJ1Q= +github.com/sagernet/sing-mux v0.3.9-0.20260927144857-0257b84c582a h1:VrlU4QE8Bh4kEeLgvvWLBtyTiuXqc6/5vf3B+xQCTRE= +github.com/sagernet/sing-mux v0.3.9-0.20260927144857-0257b84c582a/go.mod h1:jricpwCOndDsTEM1TM3piJvhL/2Mm7OhLkm+JadJJ1Q= github.com/sagernet/sing-openconnect v0.0.0-20260810065514-53aa8058f8df h1:ogyC7KPTWueN7kXFhKSdHeS5/t/zBjQgAOpWjqnrMos= github.com/sagernet/sing-openconnect v0.0.0-20260810065514-53aa8058f8df/go.mod h1:4AKZLVcvY3r54UaK2Gbnm7aN8pOwdLz+y4EP0QFZ5Eg= github.com/sagernet/sing-openvpn v0.0.0-20260729104525-103eb5fe5eb6 h1:ZAvhkor0prJ8KEX9EmTEH+gJ1WlA3ffjS8GZT6KKq9c= github.com/sagernet/sing-openvpn v0.0.0-20260729104525-103eb5fe5eb6/go.mod h1:PWX7WygD8jpwfqfaGNySXpJYTn0SOwjBI1BKHHC2+Bw= -github.com/sagernet/sing-quic v0.7.1-0.20260924092235-6a3a24d65b99 h1:zxN7hz90xUa03Nbrnp6nutArlrQaVHOOvbTYVGx4/PI= -github.com/sagernet/sing-quic v0.7.1-0.20260924092235-6a3a24d65b99/go.mod h1:rJFeVxbEej2iuyeCJ3drKW+p5rOgtLIW3XhZ6ty7Fig= +github.com/sagernet/sing-quic v0.7.2-0.20261002084117-75c3ac4fa12b h1:2Jx/URJAPt32MEbLmAR5nzH87tO6NQZLRfRFvFqBWuk= +github.com/sagernet/sing-quic v0.7.2-0.20261002084117-75c3ac4fa12b/go.mod h1:rJFeVxbEej2iuyeCJ3drKW+p5rOgtLIW3XhZ6ty7Fig= 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 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkGCKNXhbaM= github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= -github.com/sagernet/sing-snell v0.0.0-20260829071736-20f2eaec77c3 h1:wjEw0lmCSC+oqh7GD4rbMB/dk7+hZ2VopUj4xM5/Jrc= -github.com/sagernet/sing-snell v0.0.0-20260829071736-20f2eaec77c3/go.mod h1:et8Lws4f5QbOrY65DmjevHGup3mijJkhswkto6cwciM= +github.com/sagernet/sing-snell v0.0.0-20260904135315-bc5a12ac736f h1:tbTCI+iis5sTmua7a6Q7vebcSRURok/LGgnEafnQAsc= +github.com/sagernet/sing-snell v0.0.0-20260904135315-bc5a12ac736f/go.mod h1:et8Lws4f5QbOrY65DmjevHGup3mijJkhswkto6cwciM= github.com/sagernet/sing-tun v0.9.6-0.20260924001923-ddaa4ca25e3b h1:tjQUA4fQi0OWDMMYfRigzvNlBTYyP/01e+P9BT/RZ4g= github.com/sagernet/sing-tun v0.9.6-0.20260924001923-ddaa4ca25e3b/go.mod h1:3EgPst7agntRO7D6GOsiZ1l9FoqdLeuWmKT5TnWkmf0= github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca h1:5wA+IE0Fq1CGVLOgSpm0gKKZ03HzugcJ2JyhmDqeX6A= diff --git a/protocol/cloudflare/inbound.go b/protocol/cloudflare/inbound.go index e190d37e..d69dac0b 100644 --- a/protocol/cloudflare/inbound.go +++ b/protocol/cloudflare/inbound.go @@ -16,6 +16,7 @@ import ( "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-cloudflared" "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/bufio" E "github.com/sagernet/sing/common/exceptions" M "github.com/sagernet/sing/common/metadata" @@ -79,14 +80,16 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo } return &Inbound{ - Adapter: inbound.NewAdapter(C.TypeCloudflared, tag), - service: service, + Adapter: inbound.NewAdapter(C.TypeCloudflared, tag), + service: service, + references: common.FilterNotDefault([]string{options.ControlDialer.Detour, options.TunnelDialer.Detour}), }, nil } type Inbound struct { inbound.Adapter - service *cloudflared.Service + service *cloudflared.Service + references []string } func (i *Inbound) Start(stage adapter.StartStage) error { @@ -255,6 +258,10 @@ func (h *icmpRouterHandler) RouteICMPFlow(source netip.Addr, destination netip.A } } +func (i *Inbound) References() []string { + return i.references +} + var ( _ N.HandshakeSuccess = (*routedConn)(nil) _ N.ReaderWithUpstream = (*routedConn)(nil) diff --git a/protocol/group/selector.go b/protocol/group/selector.go index 3d1a8bf4..d23ffeb2 100644 --- a/protocol/group/selector.go +++ b/protocol/group/selector.go @@ -25,6 +25,7 @@ func RegisterSelector(registry *outbound.Registry) { var ( _ adapter.OutboundGroup = (*Selector)(nil) + _ adapter.Referrer = (*Selector)(nil) _ adapter.ConnectionHandler = (*Selector)(nil) _ adapter.PacketConnectionHandler = (*Selector)(nil) ) @@ -120,6 +121,10 @@ func (s *Selector) All() []string { return s.tags } +func (s *Selector) References() []string { + return []string{s.Now()} +} + func (s *Selector) SelectOutbound(tag string) bool { detour, loaded := s.outbounds[tag] if !loaded { diff --git a/protocol/group/urltest.go b/protocol/group/urltest.go index 1bb6b113..a264b2dd 100644 --- a/protocol/group/urltest.go +++ b/protocol/group/urltest.go @@ -32,6 +32,7 @@ func RegisterURLTest(registry *outbound.Registry) { var ( _ adapter.OutboundGroup = (*URLTest)(nil) _ adapter.InterfaceUpdateListener = (*URLTest)(nil) + _ adapter.Referrer = (*URLTest)(nil) ) type URLTest struct { @@ -111,6 +112,21 @@ func (s *URLTest) All() []string { return s.tags } +func (s *URLTest) References() []string { + group := s.group + if group == nil { + return nil + } + var references []string + if group.selectedOutboundTCP != nil { + references = append(references, group.selectedOutboundTCP.Tag()) + } + if group.selectedOutboundUDP != nil && group.selectedOutboundUDP != group.selectedOutboundTCP { + references = append(references, group.selectedOutboundUDP.Tag()) + } + return references +} + func (s *URLTest) URLTest(ctx context.Context) (map[string]uint16, error) { return s.group.URLTest(ctx) } @@ -488,20 +504,28 @@ func (b *urlTestBatch) test(outbounds []adapter.Outbound, link string, interval func (g *URLTestGroup) performUpdateCheck() { g.updateAccess.Lock() defer g.updateAccess.Unlock() - var updated bool + var ( + updated bool + selected bool + ) if outbound, exists := g.Select(N.NetworkTCP); outbound != nil && (g.selectedOutboundTCP == nil || (exists && outbound != g.selectedOutboundTCP)) { if g.selectedOutboundTCP != nil { updated = true } g.selectedOutboundTCP = outbound + selected = true } if outbound, exists := g.Select(N.NetworkUDP); outbound != nil && (g.selectedOutboundUDP == nil || (exists && outbound != g.selectedOutboundUDP)) { if g.selectedOutboundUDP != nil { updated = true } g.selectedOutboundUDP = outbound + selected = true } if updated { g.interruptGroup.Interrupt(g.interruptExternalConnections) } + if selected { + g.history.NotifyUpdated() + } } diff --git a/protocol/hysteria/outbound.go b/protocol/hysteria/outbound.go index 937d55db..61760d98 100644 --- a/protocol/hysteria/outbound.go +++ b/protocol/hysteria/outbound.go @@ -30,6 +30,7 @@ func RegisterOutbound(registry *outbound.Registry) { var ( _ adapter.Outbound = (*tuic.Outbound)(nil) _ adapter.InterfaceUpdateListener = (*tuic.Outbound)(nil) + _ adapter.IdleConnectionCloser = (*Outbound)(nil) ) type Outbound struct { @@ -118,6 +119,10 @@ func (h *Outbound) InterfaceUpdated(ctx context.Context) { h.client.CloseWithError(E.New("network changed")) } +func (h *Outbound) CloseIdleConnections() { + h.client.CloseIdleConnections() +} + func (h *Outbound) Close() error { return h.client.CloseWithError(os.ErrClosed) } diff --git a/protocol/hysteria2/outbound.go b/protocol/hysteria2/outbound.go index 392b0e40..fd142e5d 100644 --- a/protocol/hysteria2/outbound.go +++ b/protocol/hysteria2/outbound.go @@ -37,6 +37,7 @@ func RegisterOutbound(registry *outbound.Registry) { var ( _ adapter.Outbound = (*tuic.Outbound)(nil) _ adapter.InterfaceUpdateListener = (*tuic.Outbound)(nil) + _ adapter.IdleConnectionCloser = (*Outbound)(nil) ) type Outbound struct { @@ -210,6 +211,10 @@ func (h *Outbound) InterfaceUpdated(ctx context.Context) { h.client.CloseWithError(E.New("network changed")) } +func (h *Outbound) CloseIdleConnections() { + h.client.CloseIdleConnections() +} + func (h *Outbound) Close() error { return h.client.CloseWithError(os.ErrClosed) } diff --git a/protocol/shadowsocks/outbound.go b/protocol/shadowsocks/outbound.go index 10dd204d..33532538 100644 --- a/protocol/shadowsocks/outbound.go +++ b/protocol/shadowsocks/outbound.go @@ -26,7 +26,11 @@ func RegisterOutbound(registry *outbound.Registry) { outbound.Register[option.ShadowsocksOutboundOptions](registry, C.TypeShadowsocks, NewOutbound) } -var _ adapter.OutboundWithMultiplex = (*Outbound)(nil) +var ( + _ adapter.OutboundWithMultiplex = (*Outbound)(nil) + _ adapter.InterfaceUpdateListener = (*Outbound)(nil) + _ adapter.IdleConnectionCloser = (*Outbound)(nil) +) type Outbound struct { outbound.Adapter @@ -136,6 +140,12 @@ func (h *Outbound) InterfaceUpdated(ctx context.Context) { } } +func (h *Outbound) CloseIdleConnections() { + if h.multiplexDialer != nil { + h.multiplexDialer.CloseIdleConnections() + } +} + func (h *Outbound) Close() error { return common.Close(common.PtrOrNil(h.multiplexDialer)) } diff --git a/protocol/shadowtls/inbound.go b/protocol/shadowtls/inbound.go index abc3b8bd..5781a11c 100644 --- a/protocol/shadowtls/inbound.go +++ b/protocol/shadowtls/inbound.go @@ -26,10 +26,11 @@ func RegisterInbound(registry *inbound.Registry) { type Inbound struct { inbound.Adapter - router adapter.Router - logger logger.ContextLogger - listener *listener.Listener - service *shadowtls.Service + router adapter.Router + logger logger.ContextLogger + listener *listener.Listener + service *shadowtls.Service + references []string } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ShadowTLSInboundOptions) (adapter.Inbound, error) { @@ -48,6 +49,9 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo handshakeForServerName = make(map[string]shadowtls.HandshakeConfig) if options.HandshakeForServerName != nil { for _, entry := range options.HandshakeForServerName.Entries() { + if entry.Value.Detour != "" { + inbound.references = append(inbound.references, entry.Value.Detour) + } handshakeDialer, err := dialer.New(ctx, entry.Value.DialerOptions, entry.Value.ServerIsDomain()) if err != nil { return nil, err @@ -67,6 +71,9 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo if err != nil { return nil, err } + if options.Handshake.Detour != "" { + inbound.references = append(inbound.references, options.Handshake.Detour) + } service, err := shadowtls.NewService(shadowtls.ServiceConfig{ Version: options.Version, Password: options.Password, @@ -139,3 +146,7 @@ func (h *inboundHandler) NewConnectionEx(ctx context.Context, conn net.Conn, sou } h.router.RouteConnectionEx(ctx, conn, metadata, onClose) } + +func (h *Inbound) References() []string { + return h.references +} diff --git a/protocol/snell/outbound.go b/protocol/snell/outbound.go index 01160a5e..90ca6469 100644 --- a/protocol/snell/outbound.go +++ b/protocol/snell/outbound.go @@ -32,12 +32,16 @@ type Outbound struct { serverAddr M.Socksaddr } -var _ adapter.InterfaceUpdateListener = (*Outbound)(nil) +var ( + _ adapter.InterfaceUpdateListener = (*Outbound)(nil) + _ adapter.IdleConnectionCloser = (*Outbound)(nil) +) type snellClient interface { snellprotocol.Method DialContext(ctx context.Context, destination M.Socksaddr) (net.Conn, error) Reset() + CloseIdleConnections() Close() error } @@ -143,6 +147,10 @@ func (h *Outbound) InterfaceUpdated(ctx context.Context) { h.client.Reset() } +func (h *Outbound) CloseIdleConnections() { + h.client.CloseIdleConnections() +} + func (h *Outbound) Close() error { return h.client.Close() } diff --git a/protocol/ssh/outbound.go b/protocol/ssh/outbound.go index ec2ab27d..0033e92b 100644 --- a/protocol/ssh/outbound.go +++ b/protocol/ssh/outbound.go @@ -32,7 +32,10 @@ func RegisterOutbound(registry *outbound.Registry) { outbound.Register[option.SSHOutboundOptions](registry, C.TypeSSH, NewOutbound) } -var _ adapter.InterfaceUpdateListener = (*Outbound)(nil) +var ( + _ adapter.InterfaceUpdateListener = (*Outbound)(nil) + _ adapter.IdleConnectionCloser = (*Outbound)(nil) +) type Outbound struct { outbound.Adapter @@ -50,6 +53,8 @@ type Outbound struct { clientAccess sync.Mutex clientConn net.Conn client *ssh.Client + streams int + draining bool } func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SSHOutboundOptions) (adapter.Outbound, error) { @@ -127,14 +132,12 @@ func randomVersion() string { } func (s *Outbound) connect(ctx context.Context) (client *ssh.Client, err error) { - if s.client != nil { - return s.client, nil - } - s.clientAccess.Lock() defer s.clientAccess.Unlock() if s.client != nil { + s.streams++ + s.draining = false return s.client, nil } @@ -193,13 +196,17 @@ func (s *Outbound) connect(ctx context.Context) (client *ssh.Client, err error) s.clientConn = conn s.client = client + s.streams = 1 + s.draining = false go func() { client.Wait() conn.Close() s.clientAccess.Lock() - s.client = nil - s.clientConn = nil + if s.client == client { + s.client = nil + s.clientConn = nil + } s.clientAccess.Unlock() }() @@ -210,6 +217,42 @@ func (s *Outbound) InterfaceUpdated(ctx context.Context) { common.Close(s.clientConn) } +func (s *Outbound) CloseIdleConnections() { + s.clientAccess.Lock() + if s.client == nil { + s.clientAccess.Unlock() + return + } + s.draining = true + if s.streams > 0 { + s.clientAccess.Unlock() + return + } + clientConn := s.clientConn + s.client = nil + s.clientConn = nil + s.clientAccess.Unlock() + common.Close(clientConn) +} + +func (s *Outbound) releaseStream(client *ssh.Client) { + s.clientAccess.Lock() + if s.client != client { + s.clientAccess.Unlock() + return + } + s.streams-- + if !s.draining || s.streams > 0 { + s.clientAccess.Unlock() + return + } + clientConn := s.clientConn + s.client = nil + s.clientConn = nil + s.clientAccess.Unlock() + common.Close(clientConn) +} + func (s *Outbound) Close() error { return common.Close(s.clientConn) } @@ -221,9 +264,10 @@ func (s *Outbound) DialContext(ctx context.Context, network string, destination } conn, err := client.Dial(network, destination.String()) if err != nil { + s.releaseStream(client) return nil, err } - return &chanConnWrapper{Conn: conn}, nil + return &chanConnWrapper{Conn: conn, onClose: func() { s.releaseStream(client) }}, nil } func (s *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { @@ -232,6 +276,14 @@ func (s *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n type chanConnWrapper struct { net.Conn + closeOnce sync.Once + onClose func() +} + +func (c *chanConnWrapper) Close() error { + err := c.Conn.Close() + c.closeOnce.Do(c.onClose) + return err } func (c *chanConnWrapper) SetDeadline(t time.Time) error { diff --git a/protocol/tailscale/endpoint.go b/protocol/tailscale/endpoint.go index f46a4869..277a2394 100644 --- a/protocol/tailscale/endpoint.go +++ b/protocol/tailscale/endpoint.go @@ -68,6 +68,7 @@ import ( var ( _ adapter.OutboundWithPreferredRoutes = (*Endpoint)(nil) _ adapter.InterfaceUpdateListener = (*Endpoint)(nil) + _ adapter.Referrer = (*Endpoint)(nil) _ dialer.PacketDialerWithDestination = (*Endpoint)(nil) _ tun.Port = (*Endpoint)(nil) ) @@ -89,6 +90,7 @@ type Endpoint struct { dnsRouter adapter.DNSRouter network adapter.NetworkManager platformInterface adapter.PlatformInterface + detour string server *tsnet.Server stack *stack.Stack icmpForwarder *tun.ICMPForwarder @@ -200,6 +202,7 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL queryOptions: dialerQueryOptions, network: service.FromContext[adapter.NetworkManager](ctx), platformInterface: platformInterface, + detour: options.Detour, server: &tsnet.Server{ Dir: stateDirectory, Hostname: hostname, @@ -251,6 +254,13 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL }, nil } +func (t *Endpoint) References() []string { + if t.detour == "" { + return nil + } + return []string{t.detour} +} + func (t *Endpoint) Start(stage adapter.StartStage) error { switch stage { case adapter.StartStateInitialize: diff --git a/protocol/trojan/inbound.go b/protocol/trojan/inbound.go index 920589b4..87a84754 100644 --- a/protocol/trojan/inbound.go +++ b/protocol/trojan/inbound.go @@ -40,6 +40,7 @@ type Inbound struct { fallbackAddr M.Socksaddr fallbackAddrTLSNextProto map[string]M.Socksaddr transport adapter.V2RayServerTransport + references []string } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TrojanInboundOptions) (adapter.Inbound, error) { @@ -61,6 +62,9 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo return nil, err } inbound.tlsConfig = tlsConfig + if options.TLS.Reality != nil && options.TLS.Reality.Enabled && options.TLS.Reality.Handshake.Detour != "" { + inbound.references = []string{options.TLS.Reality.Handshake.Detour} + } } var fallbackHandler N.TCPConnectionHandlerEx if options.Fallback != nil && options.Fallback.Server != "" || len(options.FallbackForALPN) > 0 { @@ -260,3 +264,7 @@ func (h *inboundTransportHandler) NewConnectionEx(ctx context.Context, conn net. h.logger.InfoContext(ctx, "inbound connection from ", metadata.Source) (*Inbound)(h).NewConnection(ctx, conn, metadata, onClose) } + +func (h *Inbound) References() []string { + return h.references +} diff --git a/protocol/trojan/outbound.go b/protocol/trojan/outbound.go index 9abe76d4..591d44a9 100644 --- a/protocol/trojan/outbound.go +++ b/protocol/trojan/outbound.go @@ -26,7 +26,11 @@ func RegisterOutbound(registry *outbound.Registry) { outbound.Register[option.TrojanOutboundOptions](registry, C.TypeTrojan, NewOutbound) } -var _ adapter.OutboundWithMultiplex = (*Outbound)(nil) +var ( + _ adapter.OutboundWithMultiplex = (*Outbound)(nil) + _ adapter.InterfaceUpdateListener = (*Outbound)(nil) + _ adapter.IdleConnectionCloser = (*Outbound)(nil) +) type Outbound struct { outbound.Adapter @@ -122,6 +126,16 @@ func (h *Outbound) InterfaceUpdated(ctx context.Context) { } } +func (h *Outbound) CloseIdleConnections() { + transportCloser, isTransportCloser := h.transport.(adapter.IdleConnectionCloser) + if isTransportCloser { + transportCloser.CloseIdleConnections() + } + if h.multiplexDialer != nil { + h.multiplexDialer.CloseIdleConnections() + } +} + func (h *Outbound) Close() error { return common.Close(common.PtrOrNil(h.multiplexDialer), h.transport) } diff --git a/protocol/tuic/outbound.go b/protocol/tuic/outbound.go index 53f65ccb..615d41fe 100644 --- a/protocol/tuic/outbound.go +++ b/protocol/tuic/outbound.go @@ -30,7 +30,10 @@ func RegisterOutbound(registry *outbound.Registry) { outbound.Register[option.TUICOutboundOptions](registry, C.TypeTUIC, NewOutbound) } -var _ adapter.InterfaceUpdateListener = (*Outbound)(nil) +var ( + _ adapter.InterfaceUpdateListener = (*Outbound)(nil) + _ adapter.IdleConnectionCloser = (*Outbound)(nil) +) type Outbound struct { outbound.Adapter @@ -145,6 +148,10 @@ func (h *Outbound) InterfaceUpdated(ctx context.Context) { _ = h.client.CloseWithError(E.New("network changed")) } +func (h *Outbound) CloseIdleConnections() { + h.client.CloseIdleConnections() +} + func (h *Outbound) Close() error { return h.client.CloseWithError(os.ErrClosed) } diff --git a/protocol/vless/inbound.go b/protocol/vless/inbound.go index 1589bfb0..4f77fb13 100644 --- a/protocol/vless/inbound.go +++ b/protocol/vless/inbound.go @@ -35,14 +35,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 + references []string } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VLESSInboundOptions) (adapter.Inbound, error) { @@ -81,6 +82,9 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo if err != nil { return nil, err } + if options.TLS.Reality != nil && options.TLS.Reality.Enabled && options.TLS.Reality.Handshake.Detour != "" { + inbound.references = []string{options.TLS.Reality.Handshake.Detour} + } } if options.Transport != nil { inbound.transport, err = v2ray.NewServerTransport(ctx, logger, common.PtrValueOrDefault(options.Transport), inbound.tlsConfig, (*inboundTransportHandler)(inbound)) @@ -220,3 +224,7 @@ func (h *inboundTransportHandler) NewConnectionEx(ctx context.Context, conn net. h.logger.InfoContext(ctx, "inbound connection from ", metadata.Source) (*Inbound)(h).NewConnection(ctx, conn, metadata, onClose) } + +func (h *Inbound) References() []string { + return h.references +} diff --git a/protocol/vless/outbound.go b/protocol/vless/outbound.go index 1356a589..8be11db2 100644 --- a/protocol/vless/outbound.go +++ b/protocol/vless/outbound.go @@ -27,7 +27,11 @@ func RegisterOutbound(registry *outbound.Registry) { outbound.Register[option.VLESSOutboundOptions](registry, C.TypeVLESS, NewOutbound) } -var _ adapter.OutboundWithMultiplex = (*Outbound)(nil) +var ( + _ adapter.OutboundWithMultiplex = (*Outbound)(nil) + _ adapter.InterfaceUpdateListener = (*Outbound)(nil) + _ adapter.IdleConnectionCloser = (*Outbound)(nil) +) type Outbound struct { outbound.Adapter @@ -142,6 +146,16 @@ func (h *Outbound) InterfaceUpdated(ctx context.Context) { } } +func (h *Outbound) CloseIdleConnections() { + transportCloser, isTransportCloser := h.transport.(adapter.IdleConnectionCloser) + if isTransportCloser { + transportCloser.CloseIdleConnections() + } + if h.multiplexDialer != nil { + h.multiplexDialer.CloseIdleConnections() + } +} + func (h *Outbound) Close() error { return common.Close(common.PtrOrNil(h.multiplexDialer), h.transport) } diff --git a/protocol/vmess/outbound.go b/protocol/vmess/outbound.go index 7b16fc90..4ac274b5 100644 --- a/protocol/vmess/outbound.go +++ b/protocol/vmess/outbound.go @@ -27,7 +27,11 @@ func RegisterOutbound(registry *outbound.Registry) { outbound.Register[option.VMessOutboundOptions](registry, C.TypeVMess, NewOutbound) } -var _ adapter.OutboundWithMultiplex = (*Outbound)(nil) +var ( + _ adapter.OutboundWithMultiplex = (*Outbound)(nil) + _ adapter.InterfaceUpdateListener = (*Outbound)(nil) + _ adapter.IdleConnectionCloser = (*Outbound)(nil) +) type Outbound struct { outbound.Adapter @@ -120,6 +124,16 @@ func (h *Outbound) InterfaceUpdated(ctx context.Context) { } } +func (h *Outbound) CloseIdleConnections() { + transportCloser, isTransportCloser := h.transport.(adapter.IdleConnectionCloser) + if isTransportCloser { + transportCloser.CloseIdleConnections() + } + if h.multiplexDialer != nil { + h.multiplexDialer.CloseIdleConnections() + } +} + func (h *Outbound) Close() error { return common.Close(common.PtrOrNil(h.multiplexDialer), h.transport) } diff --git a/route/reference.go b/route/reference.go new file mode 100644 index 00000000..e2f5ba41 --- /dev/null +++ b/route/reference.go @@ -0,0 +1,235 @@ +package route + +import ( + "context" + "slices" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/urltest" + "github.com/sagernet/sing-box/experimental/clashmode" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/observable" + "github.com/sagernet/sing/service" +) + +var _ adapter.LifecycleService = (*ReferenceManager)(nil) + +type ReferenceManager struct { + ctx context.Context + logger log.ContextLogger + rules []option.Rule + dnsRules []option.DNSRule + staticOutbounds []string + staticTransports []string + subscriber *observable.Subscriber[struct{}] + unreferencedOutbounds map[string]bool + unreferencedTransports map[string]bool +} + +func NewReferenceManager(ctx context.Context, logger log.ContextLogger, options option.Options) *ReferenceManager { + var ( + staticOutbounds []string + staticTransports []string + ) + if options.NTP != nil && options.NTP.Enabled && options.NTP.Detour != "" { + staticOutbounds = append(staticOutbounds, options.NTP.Detour) + } + for _, outboundOptions := range options.Outbounds { + staticTransports = appendDomainResolver(staticTransports, outboundOptions.Options) + } + for _, endpointOptions := range options.Endpoints { + staticTransports = appendDomainResolver(staticTransports, endpointOptions.Options) + } + var ( + rules []option.Rule + dnsRules []option.DNSRule + ) + if options.Route != nil { + rules = options.Route.Rules + } + if options.DNS != nil { + dnsRules = options.DNS.Rules + } + return &ReferenceManager{ + ctx: ctx, + logger: logger, + rules: rules, + dnsRules: dnsRules, + staticOutbounds: staticOutbounds, + staticTransports: staticTransports, + } +} + +func appendDomainResolver(transports []string, rawOptions any) []string { + dialerOptionsWrapper, isDialerOptionsWrapper := rawOptions.(option.DialerOptionsWrapper) + if !isDialerOptionsWrapper { + return transports + } + dialerOptions := dialerOptionsWrapper.TakeDialerOptions() + if dialerOptions.DomainResolver == nil || dialerOptions.DomainResolver.Server == "" { + return transports + } + return append(transports, dialerOptions.DomainResolver.Server) +} + +func (m *ReferenceManager) Name() string { + return "reference manager" +} + +func (m *ReferenceManager) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStarted { + return nil + } + m.subscriber = observable.NewSubscriber[struct{}](1) + history := service.PtrFromContext[urltest.HistoryStorage](m.ctx) + if history != nil { + history.AddUpdateHook(m.subscriber) + } + clashMode := service.PtrFromContext[clashmode.Manager](m.ctx) + if clashMode != nil { + clashMode.AddUpdateHook(m.subscriber) + } + m.update() + go m.loop() + return nil +} + +func (m *ReferenceManager) Close() error { + if m.subscriber != nil { + m.subscriber.Close() + } + return nil +} + +func (m *ReferenceManager) loop() { + subscription, done := m.subscriber.Subscription() + for { + select { + case <-subscription: + case <-done: + return + } + m.update() + } +} + +func (m *ReferenceManager) update() { + var mode string + clashMode := service.PtrFromContext[clashmode.Manager](m.ctx) + if clashMode != nil { + mode = clashMode.Mode() + } + outboundManager := service.FromContext[adapter.OutboundManager](m.ctx) + endpointManager := service.FromContext[adapter.EndpointManager](m.ctx) + inboundManager := service.FromContext[adapter.InboundManager](m.ctx) + serviceManager := service.FromContext[adapter.ServiceManager](m.ctx) + transportManager := service.FromContext[adapter.DNSTransportManager](m.ctx) + networkManager := service.FromContext[adapter.NetworkManager](m.ctx) + httpClientManager := service.FromContext[adapter.HTTPClientManager](m.ctx) + + transportQueue := slices.Clone(m.staticTransports) + outboundQueue := slices.Clone(m.staticOutbounds) + if !collectDNSRuleReferences(m.dnsRules, mode, &transportQueue) { + defaultTransport := transportManager.Default() + if defaultTransport != nil { + transportQueue = append(transportQueue, defaultTransport.Tag()) + } + } + transportQueue = append(transportQueue, networkManager.DefaultOptions().DomainResolver) + if !collectRuleReferences(m.rules, mode, &outboundQueue, &transportQueue) { + defaultOutbound := outboundManager.Default() + if defaultOutbound != nil { + outboundQueue = append(outboundQueue, defaultOutbound.Tag()) + } + } + for _, endpoint := range endpointManager.Endpoints() { + outboundQueue = append(outboundQueue, endpoint.Tag()) + } + for _, inbound := range inboundManager.Inbounds() { + referrer, isReferrer := inbound.(adapter.Referrer) + if isReferrer { + outboundQueue = append(outboundQueue, referrer.References()...) + } + } + for _, boxService := range serviceManager.Services() { + referrer, isReferrer := boxService.(adapter.Referrer) + if isReferrer { + outboundQueue = append(outboundQueue, referrer.References()...) + } + } + referrer, isReferrer := httpClientManager.(adapter.Referrer) + if isReferrer { + outboundQueue = append(outboundQueue, referrer.References()...) + } + + referencedTransports := make(map[string]bool) + for len(transportQueue) > 0 { + tag := transportQueue[0] + transportQueue = transportQueue[1:] + if tag == "" || referencedTransports[tag] { + continue + } + transport, loaded := transportManager.Transport(tag) + if !loaded { + continue + } + referencedTransports[tag] = true + transportQueue = append(transportQueue, transport.Dependencies()...) + transportReferrer, isTransportReferrer := transport.(adapter.Referrer) + if isTransportReferrer { + outboundQueue = append(outboundQueue, transportReferrer.References()...) + } + } + referencedOutbounds := make(map[string]bool) + for len(outboundQueue) > 0 { + tag := outboundQueue[0] + outboundQueue = outboundQueue[1:] + if tag == "" || referencedOutbounds[tag] { + continue + } + outbound, loaded := outboundManager.Outbound(tag) + if !loaded { + continue + } + referencedOutbounds[tag] = true + outboundReferrer, isOutboundReferrer := outbound.(adapter.Referrer) + if isOutboundReferrer { + outboundQueue = append(outboundQueue, outboundReferrer.References()...) + } else { + outboundQueue = append(outboundQueue, outbound.Dependencies()...) + } + } + + unreferencedOutbounds := make(map[string]bool) + for _, outbound := range outboundManager.Outbounds() { + tag := outbound.Tag() + if referencedOutbounds[tag] { + continue + } + unreferencedOutbounds[tag] = true + closer, isCloser := outbound.(adapter.IdleConnectionCloser) + if !isCloser { + continue + } + if !m.unreferencedOutbounds[tag] { + m.logger.Debug("outbound/", outbound.Type(), "[", tag, "] is unreferenced, closing idle connections") + } + closer.CloseIdleConnections() + } + m.unreferencedOutbounds = unreferencedOutbounds + + unreferencedTransports := make(map[string]bool) + for _, transport := range transportManager.Transports() { + tag := transport.Tag() + if referencedTransports[tag] { + continue + } + unreferencedTransports[tag] = true + if m.unreferencedTransports != nil && !m.unreferencedTransports[tag] { + m.logger.Debug("dns/", transport.Type(), "[", tag, "] is unreferenced, resetting") + transport.Reset() + } + } + m.unreferencedTransports = unreferencedTransports +} diff --git a/route/reference_rule.go b/route/reference_rule.go new file mode 100644 index 00000000..86464b65 --- /dev/null +++ b/route/reference_rule.go @@ -0,0 +1,168 @@ +package route + +import ( + "reflect" + "strings" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" +) + +type staticMatch uint8 + +const ( + staticMatchUnknown staticMatch = iota + staticMatchNever + staticMatchAlways +) + +func (m staticMatch) invert() staticMatch { + switch m { + case staticMatchNever: + return staticMatchAlways + case staticMatchAlways: + return staticMatchNever + default: + return staticMatchUnknown + } +} + +func evaluateClashMode(clashMode string, mode string, invert bool, otherConditions bool) staticMatch { + match := staticMatchUnknown + if clashMode != "" && !strings.EqualFold(clashMode, mode) { + match = staticMatchNever + } else if !otherConditions { + match = staticMatchAlways + } + if invert { + match = match.invert() + } + return match +} + +func evaluateLogical(matches []staticMatch, logicalMode string, invert bool) staticMatch { + var match staticMatch + if logicalMode == C.LogicalTypeAnd { + match = staticMatchAlways + } else { + match = staticMatchNever + } + for _, ruleMatch := range matches { + if logicalMode == C.LogicalTypeAnd && ruleMatch == staticMatchNever { + match = staticMatchNever + break + } + if logicalMode == C.LogicalTypeOr && ruleMatch == staticMatchAlways { + match = staticMatchAlways + break + } + if ruleMatch == staticMatchUnknown { + match = staticMatchUnknown + } + } + if invert { + match = match.invert() + } + return match +} + +func evaluateRule(rule option.Rule, mode string) staticMatch { + switch rule.Type { + case C.RuleTypeDefault: + conditions := rule.DefaultOptions.RawDefaultRule + conditions.ClashMode = "" + conditions.Invert = false + return evaluateClashMode(rule.DefaultOptions.ClashMode, mode, rule.DefaultOptions.Invert, !reflect.DeepEqual(conditions, option.RawDefaultRule{})) + case C.RuleTypeLogical: + matches := make([]staticMatch, 0, len(rule.LogicalOptions.Rules)) + for _, subRule := range rule.LogicalOptions.Rules { + matches = append(matches, evaluateRule(subRule, mode)) + } + return evaluateLogical(matches, rule.LogicalOptions.Mode, rule.LogicalOptions.Invert) + default: + return staticMatchUnknown + } +} + +func evaluateDNSRule(rule option.DNSRule, mode string) staticMatch { + switch rule.Type { + case C.RuleTypeDefault: + conditions := rule.DefaultOptions.RawDefaultDNSRule + conditions.ClashMode = "" + conditions.Invert = false + return evaluateClashMode(rule.DefaultOptions.ClashMode, mode, rule.DefaultOptions.Invert, !reflect.DeepEqual(conditions, option.RawDefaultDNSRule{})) + case C.RuleTypeLogical: + matches := make([]staticMatch, 0, len(rule.LogicalOptions.Rules)) + for _, subRule := range rule.LogicalOptions.Rules { + matches = append(matches, evaluateDNSRule(subRule, mode)) + } + return evaluateLogical(matches, rule.LogicalOptions.Mode, rule.LogicalOptions.Invert) + default: + return staticMatchUnknown + } +} + +func collectRuleReferences(rules []option.Rule, mode string, outbounds *[]string, transports *[]string) (shadowed bool) { + for _, rule := range rules { + match := evaluateRule(rule, mode) + if match == staticMatchNever { + continue + } + var action option.RuleAction + switch rule.Type { + case C.RuleTypeDefault: + action = rule.DefaultOptions.RuleAction + case C.RuleTypeLogical: + action = rule.LogicalOptions.RuleAction + } + var final bool + switch action.Action { + case C.RuleActionTypeRoute: + *outbounds = append(*outbounds, action.RouteOptions.Outbound) + final = true + case C.RuleActionTypeBypass: + if action.BypassOptions.Outbound != "" { + *outbounds = append(*outbounds, action.BypassOptions.Outbound) + final = true + } + case C.RuleActionTypeDirect, C.RuleActionTypeReject, C.RuleActionTypeHijackDNS: + final = true + case C.RuleActionTypeResolve: + *transports = append(*transports, action.ResolveOptions.Server) + } + if final && match == staticMatchAlways { + return true + } + } + return false +} + +func collectDNSRuleReferences(rules []option.DNSRule, mode string, transports *[]string) (shadowed bool) { + for _, rule := range rules { + match := evaluateDNSRule(rule, mode) + if match == staticMatchNever { + continue + } + var action option.DNSRuleAction + switch rule.Type { + case C.RuleTypeDefault: + action = rule.DefaultOptions.DNSRuleAction + case C.RuleTypeLogical: + action = rule.LogicalOptions.DNSRuleAction + } + var final bool + switch action.Action { + case C.RuleActionTypeRoute: + *transports = append(*transports, action.RouteOptions.Server) + final = true + case C.RuleActionTypeEvaluate: + *transports = append(*transports, action.EvaluateOptions.Server) + case C.RuleActionTypeRespond, C.RuleActionTypeReject, C.RuleActionTypePredefined: + final = true + } + if final && match == staticMatchAlways { + return true + } + } + return false +} diff --git a/service/ccm/service.go b/service/ccm/service.go index f143c693..a211a3ef 100644 --- a/service/ccm/service.go +++ b/service/ccm/service.go @@ -114,6 +114,7 @@ type Service struct { ctx context.Context logger log.ContextLogger credentialPath string + detour string credentials *oauthCredentials users []option.CCMUser httpClient *http.Client @@ -168,6 +169,7 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio service := &Service{ Adapter: boxService.NewAdapter(C.TypeCCM, tag), + detour: options.Detour, ctx: ctx, logger: logger, credentialPath: options.CredentialPath, @@ -594,3 +596,10 @@ func (s *Service) Close() error { return err } + +func (s *Service) References() []string { + if s.detour == "" { + return nil + } + return []string{s.detour} +} diff --git a/service/derp/service.go b/service/derp/service.go index e251a799..485bc41e 100644 --- a/service/derp/service.go +++ b/service/derp/service.go @@ -512,3 +512,9 @@ func (d *Service) loopSTUNPacket(packetConn *net.UDPConn) { packetConn.WriteMsgUDPAddrPort(stun.Response(txid, addrPort), oob[:oobN], addrPort) } } + +func (d *Service) References() []string { + return common.FilterNotDefault(common.Map(d.meshWith, func(it *option.DERPMeshOptions) string { + return it.Detour + })) +} diff --git a/service/ocm/service.go b/service/ocm/service.go index d02e923e..5b213e23 100644 --- a/service/ocm/service.go +++ b/service/ocm/service.go @@ -128,6 +128,7 @@ type Service struct { ctx context.Context logger log.ContextLogger credentialPath string + detour string credentials *oauthCredentials users []option.OCMUser dialer N.Dialer @@ -187,6 +188,7 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio service := &Service{ Adapter: boxService.NewAdapter(C.TypeOCM, tag), + detour: options.Detour, ctx: ctx, logger: logger, credentialPath: options.CredentialPath, @@ -706,3 +708,10 @@ func (s *Service) startWebSocketShutdown() []*webSocketSession { } return webSocketSessions } + +func (s *Service) References() []string { + if s.detour == "" { + return nil + } + return []string{s.detour} +} diff --git a/service/powerreport/promote.go b/service/powerreport/promote.go index f1b0f8bb..e410396a 100644 --- a/service/powerreport/promote.go +++ b/service/powerreport/promote.go @@ -6,15 +6,12 @@ import ( "strconv" ) -func PromoteDraft(basePath string) { - promoteDirectory(filepath.Join(basePath, DraftDirectoryName), filepath.Join(basePath, ReportsDirectoryName)) +func DiscardDraft(basePath string) { + os.RemoveAll(filepath.Join(basePath, DraftDirectoryName)) } func finalizeDraft(draftPath string) { - promoteDirectory(draftPath, filepath.Join(filepath.Dir(draftPath), ReportsDirectoryName)) -} - -func promoteDirectory(draftPath string, reportsPath string) { + reportsPath := filepath.Join(filepath.Dir(draftPath), ReportsDirectoryName) info, err := os.Stat(draftPath) if err != nil || !info.IsDir() { return diff --git a/service/powerreport/recorder.go b/service/powerreport/recorder.go index 0b903a8d..5cb2767a 100644 --- a/service/powerreport/recorder.go +++ b/service/powerreport/recorder.go @@ -162,7 +162,7 @@ func (r *Recorder) Start() error { if r.started { return nil } - PromoteDraft(filepath.Dir(r.draftPath)) + DiscardDraft(filepath.Dir(r.draftPath)) err := os.MkdirAll(r.draftPath, 0o777) if err != nil { return E.Cause(err, "create power report draft directory") diff --git a/service/usbip/client.go b/service/usbip/client.go index ae49165e..231c447f 100644 --- a/service/usbip/client.go +++ b/service/usbip/client.go @@ -20,6 +20,7 @@ type ClientService struct { ctx context.Context logger log.ContextLogger inner *usbip.ClientService + detour string } func NewClientService(ctx context.Context, logger log.ContextLogger, tag string, options option.USBIPClientServiceOptions) (adapter.Service, error) { @@ -47,6 +48,7 @@ func NewClientService(ctx context.Context, logger log.ContextLogger, tag string, ctx: ctx, logger: logger, inner: inner, + detour: options.Detour, }, nil } @@ -60,3 +62,10 @@ func (s *ClientService) Start(stage adapter.StartStage) error { func (s *ClientService) Close() error { return s.inner.Close() } + +func (s *ClientService) References() []string { + if s.detour == "" { + return nil + } + return []string{s.detour} +} diff --git a/transport/v2raygrpclite/client.go b/transport/v2raygrpclite/client.go index 7a42cf41..d8b3400e 100644 --- a/transport/v2raygrpclite/client.go +++ b/transport/v2raygrpclite/client.go @@ -126,6 +126,10 @@ func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { return conn, nil } +func (c *Client) CloseIdleConnections() { + c.transport.CloseIdleConnections() +} + func (c *Client) Close() error { v2rayhttp.ResetTransport(c.transport) return nil diff --git a/transport/v2rayhttp/client.go b/transport/v2rayhttp/client.go index bba65960..0b64c09f 100644 --- a/transport/v2rayhttp/client.go +++ b/transport/v2rayhttp/client.go @@ -170,6 +170,10 @@ func (c *Client) dialHTTP2(ctx context.Context) (net.Conn, error) { return conn, nil } +func (c *Client) CloseIdleConnections() { + CloseIdleConnections(c.transport) +} + func (c *Client) Close() error { c.transport.Store(ResetTransport(c.transport.Load())) return nil diff --git a/transport/v2rayquic/client.go b/transport/v2rayquic/client.go index 4e70e818..105785d5 100644 --- a/transport/v2rayquic/client.go +++ b/transport/v2rayquic/client.go @@ -28,10 +28,54 @@ type Client struct { tlsConfig tls.Config quicConfig *quic.Config connAccess sync.Mutex - conn common.TypedValue[*quic.Conn] + conn common.TypedValue[*clientConnection] rawConn net.Conn } +type clientConnection struct { + *quic.Conn + access sync.Mutex + streams int + draining bool + closed bool +} + +func (c *clientConnection) active() bool { + c.access.Lock() + closed := c.closed + c.access.Unlock() + return !closed && !common.Done(c.Context()) +} + +func (c *clientConnection) acquireStream() bool { + c.access.Lock() + defer c.access.Unlock() + if c.closed { + return false + } + c.streams++ + c.draining = false + return true +} + +func (c *clientConnection) releaseStream() { + c.access.Lock() + c.streams-- + closed := c.draining && c.streams == 0 && c.markClosedLocked() + c.access.Unlock() + if closed { + c.CloseWithError(0, "") + } +} + +func (c *clientConnection) markClosedLocked() bool { + if c.closed { + return false + } + c.closed = true + return true +} + func NewClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, options option.V2RayQUICOptions, tlsConfig tls.Config) (adapter.V2RayClientTransport, error) { quicConfig := &quic.Config{ DisablePathMTUDiscovery: !C.IsLinux && !C.IsWindows, @@ -48,15 +92,15 @@ func NewClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, opt }, nil } -func (c *Client) offer() (*quic.Conn, error) { +func (c *Client) offer() (*clientConnection, error) { conn := c.conn.Load() - if conn != nil && !common.Done(conn.Context()) { + if conn != nil && conn.active() { return conn, nil } c.connAccess.Lock() defer c.connAccess.Unlock() conn = c.conn.Load() - if conn != nil && !common.Done(conn.Context()) { + if conn != nil && conn.active() { return conn, nil } conn, err := c.offerNew() @@ -66,7 +110,7 @@ func (c *Client) offer() (*quic.Conn, error) { return conn, nil } -func (c *Client) offerNew() (*quic.Conn, error) { +func (c *Client) offerNew() (*clientConnection, error) { udpConn, err := c.dialer.DialContext(c.ctx, "udp", c.serverAddr) if err != nil { return nil, err @@ -82,21 +126,42 @@ func (c *Client) offerNew() (*quic.Conn, error) { <-quicConn.Context().Done() udpConn.Close() }() - c.conn.Store(quicConn) + conn := &clientConnection{Conn: quicConn} + c.conn.Store(conn) c.rawConn = udpConn - return quicConn, nil + return conn, nil } func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { - conn, err := c.offer() - if err != nil { - return nil, err + for { + conn, err := c.offer() + if err != nil { + return nil, err + } + if !conn.acquireStream() { + continue + } + stream, err := conn.OpenStream() + if err != nil { + conn.releaseStream() + return nil, err + } + return &StreamWrapper{Conn: conn.Conn, Stream: stream, onClose: conn.releaseStream}, nil } - stream, err := conn.OpenStream() - if err != nil { - return nil, err +} + +func (c *Client) CloseIdleConnections() { + conn := c.conn.Load() + if conn == nil { + return + } + conn.access.Lock() + conn.draining = true + closed := conn.streams == 0 && conn.markClosedLocked() + conn.access.Unlock() + if closed { + conn.CloseWithError(0, "") } - return &StreamWrapper{Conn: conn, Stream: stream}, nil } func (c *Client) Close() error { diff --git a/transport/v2rayquic/stream.go b/transport/v2rayquic/stream.go index 411c57a4..7be6e2a9 100644 --- a/transport/v2rayquic/stream.go +++ b/transport/v2rayquic/stream.go @@ -2,6 +2,7 @@ package v2rayquic import ( "net" + "sync" "time" "github.com/sagernet/quic-go" @@ -11,6 +12,8 @@ import ( type StreamWrapper struct { Conn *quic.Conn *quic.Stream + closeOnce sync.Once + onClose func() } func (s *StreamWrapper) Read(p []byte) (n int, err error) { @@ -41,5 +44,8 @@ func (s *StreamWrapper) Close() error { // quic-go's Stream.Close does not unblock a Write blocked on flow control, // but a past write deadline does; buffered data and the FIN are unaffected. s.Stream.SetWriteDeadline(time.Now()) + if s.onClose != nil { + s.closeOnce.Do(s.onClose) + } return nil } From eb7deeae8aa6160310202a0792e1addd681e292b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 3 Sep 2026 13:17:12 +0800 Subject: [PATCH 44/66] Improve power report attribution and sampling --- adapter/inbound.go | 10 ++ common/dialer/default.go | 12 ++ common/mux/client.go | 4 +- daemon/instance.go | 21 +++ dns/router.go | 6 +- protocol/anytls/outbound.go | 2 +- service/powerreport/device_darwin.go | 41 +++++ service/powerreport/device_darwin.h | 7 + service/powerreport/device_darwin.m | 121 +++++++++++++ service/powerreport/device_stub.go | 7 + service/powerreport/record.go | 45 ++++- service/powerreport/recorder.go | 227 +++++++++++++++++++++++-- service/powerreport/sysstats_darwin.go | 41 +++-- service/powerreport/sysstats_linux.go | 11 +- service/powerreport/traffic.go | 73 ++++++++ transport/wireguard/endpoint.go | 3 + 16 files changed, 588 insertions(+), 43 deletions(-) create mode 100644 service/powerreport/device_darwin.go create mode 100644 service/powerreport/device_darwin.h create mode 100644 service/powerreport/device_darwin.m create mode 100644 service/powerreport/device_stub.go create mode 100644 service/powerreport/traffic.go diff --git a/adapter/inbound.go b/adapter/inbound.go index ae1067d9..954ec9c4 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -189,6 +189,16 @@ func DNSTransportTagFromContext(ctx context.Context) (string, bool) { return transportTag, loaded } +func ContextForMultiplexSession(ctx context.Context) context.Context { + var sessionContext InboundContext + metadata := ContextFrom(ctx) + if metadata != nil { + sessionContext.Outbound = metadata.Outbound + } + ctx = ContextWithDNSTransportTag(ctx, "") + return WithContext(ctx, &sessionContext) +} + func WithContext(ctx context.Context, inboundContext *InboundContext) context.Context { return context.WithValue(ctx, (*inboundContextKey)(nil), inboundContext) } diff --git a/common/dialer/default.go b/common/dialer/default.go index a48f675d..154835dc 100644 --- a/common/dialer/default.go +++ b/common/dialer/default.go @@ -431,9 +431,15 @@ func (d *DefaultDialer) trackConn(ctx context.Context, destination M.Socksaddr, if recorder != nil { recorder.CountConnectionOpened() attribution := d.dialAttribution(ctx, destination) + outboundCounter := recorder.TrafficCounter(powerreport.TrafficOutbound, attribution.Outbound) + dnsCounter := recorder.TrafficCounter(powerreport.TrafficDNSTransport, attribution.DNS) conn = bufio.NewCounterConn(conn, []N.CountFunc{func(n int64) { + outboundCounter.CountIn(n) + dnsCounter.CountIn(n) recorder.Touch(powerreport.DirectionInbound, int(n), attribution) }}, []N.CountFunc{func(n int64) { + outboundCounter.CountOut(n) + dnsCounter.CountOut(n) recorder.Touch(powerreport.DirectionOutbound, int(n), attribution) }}) } @@ -453,9 +459,15 @@ func (d *DefaultDialer) trackPacketConn(ctx context.Context, destination M.Socks if recorder != nil { recorder.CountConnectionOpened() attribution := d.dialAttribution(ctx, destination) + outboundCounter := recorder.TrafficCounter(powerreport.TrafficOutbound, attribution.Outbound) + dnsCounter := recorder.TrafficCounter(powerreport.TrafficDNSTransport, attribution.DNS) conn = bufio.NewNetPacketConn(bufio.NewCounterPacketConn(bufio.NewPacketConn(conn), []N.CountFunc{func(n int64) { + outboundCounter.CountIn(n) + dnsCounter.CountIn(n) recorder.Touch(powerreport.DirectionInbound, int(n), attribution) }}, []N.CountFunc{func(n int64) { + outboundCounter.CountOut(n) + dnsCounter.CountOut(n) recorder.Touch(powerreport.DirectionOutbound, int(n), attribution) }})) } diff --git a/common/mux/client.go b/common/mux/client.go index bf103355..7e679b5c 100644 --- a/common/mux/client.go +++ b/common/mux/client.go @@ -51,9 +51,9 @@ type clientDialer struct { } func (d *clientDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { - return d.Dialer.DialContext(adapter.OverrideContext(ctx), network, destination) + return d.Dialer.DialContext(adapter.ContextForMultiplexSession(ctx), network, destination) } func (d *clientDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { - return d.Dialer.ListenPacket(adapter.OverrideContext(ctx), destination) + return d.Dialer.ListenPacket(adapter.ContextForMultiplexSession(ctx), destination) } diff --git a/daemon/instance.go b/daemon/instance.go index 5e7502ce..4040693f 100644 --- a/daemon/instance.go +++ b/daemon/instance.go @@ -14,9 +14,11 @@ import ( "github.com/sagernet/sing-box/experimental/locale" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/service/powerreport" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/x/list" "github.com/sagernet/sing/service" "github.com/sagernet/sing/service/pause" ) @@ -30,6 +32,7 @@ type Instance struct { trafficManager *trafficcontrol.Manager cacheFile adapter.CacheFile pauseManager pause.Manager + pauseCallback *list.Element[pause.Callback] urlTestHistoryStorage *urltest.HistoryStorage outboundManager adapter.OutboundManager endpointManager adapter.EndpointManager @@ -135,6 +138,7 @@ func (s *StartedService) newInstance(ctx context.Context, profileContent string, i.clashMode = service.PtrFromContext[clashmode.Manager](ctx) i.trafficManager = service.PtrFromContext[trafficcontrol.Manager](ctx) i.pauseManager = service.FromContext[pause.Manager](ctx) + i.registerPauseCallback(ctx) i.cacheFile = service.FromContext[adapter.CacheFile](ctx) i.outboundManager = service.FromContext[adapter.OutboundManager](ctx) i.endpointManager = service.FromContext[adapter.EndpointManager](ctx) @@ -164,10 +168,27 @@ func (i *Instance) Start() error { func (i *Instance) Close() error { i.cancel() + if i.pauseCallback != nil { + i.pauseManager.UnregisterCallback(i.pauseCallback) + i.pauseCallback = nil + } i.urlTestHistoryStorage.Close() return i.instance.Close() } +func (i *Instance) registerPauseCallback(ctx context.Context) { + powerManager := service.FromContext[*powerreport.Manager](ctx) + if i.pauseManager == nil || powerManager == nil { + return + } + i.pauseCallback = i.pauseManager.RegisterCallback(func(event int) { + recorder := powerManager.Recorder() + if recorder != nil { + recorder.RecordPauseEvent(event) + } + }) +} + func (i *Instance) Box() *box.Box { return i.instance } diff --git a/dns/router.go b/dns/router.go index e7c0e964..d5b07031 100644 --- a/dns/router.go +++ b/dns/router.go @@ -1059,7 +1059,11 @@ func (r *Router) prepareExchange(ctx context.Context, message *mDNS.Msg) (*dnsEx if r.powerManager != nil { recorder := r.powerManager.Recorder() if recorder != nil { - recorder.CountDNSQuery() + var domain string + if len(message.Question) == 1 { + domain = message.Question[0].Name + } + recorder.CountDNSQuery(domain) } } if len(message.Question) != 1 { diff --git a/protocol/anytls/outbound.go b/protocol/anytls/outbound.go index 83f54477..b4c07521 100644 --- a/protocol/anytls/outbound.go +++ b/protocol/anytls/outbound.go @@ -131,7 +131,7 @@ func (d anytlsDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) } func (h *Outbound) dialOut(ctx context.Context) (net.Conn, error) { - return h.dialer.DialTLSContext(ctx, h.server) + return h.dialer.DialTLSContext(adapter.ContextForMultiplexSession(ctx), h.server) } func (h *Outbound) MultiplexEnabled() bool { diff --git a/service/powerreport/device_darwin.go b/service/powerreport/device_darwin.go new file mode 100644 index 00000000..7df868cd --- /dev/null +++ b/service/powerreport/device_darwin.go @@ -0,0 +1,41 @@ +package powerreport + +/* +#cgo LDFLAGS: -framework Foundation +#cgo ios LDFLAGS: -framework UIKit +#cgo !ios LDFLAGS: -framework CoreFoundation -framework IOKit + +#include "device_darwin.h" +*/ +import "C" + +func readDeviceState() (deviceState, bool) { + var batteryLevel C.int + state := deviceState{ + LowPowerMode: C.boxPowerLowPowerMode() != 0, + ThermalState: thermalStateName(int(C.boxPowerThermalState())), + } + switch C.boxPowerSource(&batteryLevel) { + case 1: + state.PowerSource = "battery" + case 2: + state.PowerSource = "ac" + } + state.BatteryLevel = int(batteryLevel) + return state, true +} + +func thermalStateName(state int) string { + switch state { + case 0: + return "nominal" + case 1: + return "fair" + case 2: + return "serious" + case 3: + return "critical" + default: + return "" + } +} diff --git a/service/powerreport/device_darwin.h b/service/powerreport/device_darwin.h new file mode 100644 index 00000000..d7bcd423 --- /dev/null +++ b/service/powerreport/device_darwin.h @@ -0,0 +1,7 @@ +#pragma once + +int boxPowerLowPowerMode(void); + +int boxPowerThermalState(void); + +int boxPowerSource(int *level); diff --git a/service/powerreport/device_darwin.m b/service/powerreport/device_darwin.m new file mode 100644 index 00000000..4a8d5efa --- /dev/null +++ b/service/powerreport/device_darwin.m @@ -0,0 +1,121 @@ +#import +#import + +#include "device_darwin.h" + +#if TARGET_OS_IOS +#import +#include +#elif !TARGET_OS_IPHONE +#include +#include +#include +#endif + +int boxPowerLowPowerMode(void) { + @autoreleasepool { + return [[NSProcessInfo processInfo] isLowPowerModeEnabled] ? 1 : 0; + } +} + +int boxPowerThermalState(void) { + @autoreleasepool { + return (int) [[NSProcessInfo processInfo] thermalState]; + } +} + +#if TARGET_OS_IOS + +__attribute__((constructor)) static void boxPowerInitBatteryMonitoring(void) { + dispatch_async(dispatch_get_main_queue(), ^{ + @autoreleasepool { + [UIDevice currentDevice].batteryMonitoringEnabled = YES; + } + }); +} + +int boxPowerSource(int *level) { + @autoreleasepool { + *level = 0; + __block int source = 0; + __block float batteryLevel = -1; + dispatch_block_t read = ^{ + UIDevice *device = [UIDevice currentDevice]; + switch (device.batteryState) { + case UIDeviceBatteryStateUnplugged: + source = 1; + break; + case UIDeviceBatteryStateCharging: + case UIDeviceBatteryStateFull: + source = 2; + break; + default: + break; + } + batteryLevel = device.batteryLevel; + }; + if ([NSThread isMainThread]) { + read(); + } else { + dispatch_sync(dispatch_get_main_queue(), read); + } + if (batteryLevel >= 0) { + *level = (int) lroundf(batteryLevel * 100); + } + return source; + } +} + +#elif TARGET_OS_IPHONE + +int boxPowerSource(int *level) { + *level = 0; + return 0; +} + +#else + +int boxPowerSource(int *level) { + *level = 0; + CFTypeRef info = IOPSCopyPowerSourcesInfo(); + if (info == NULL) { + return 0; + } + CFArrayRef sources = IOPSCopyPowerSourcesList(info); + if (sources == NULL) { + CFRelease(info); + return 0; + } + int result = 0; + if (CFArrayGetCount(sources) > 0) { + CFDictionaryRef source = IOPSGetPowerSourceDescription(info, CFArrayGetValueAtIndex(sources, 0)); + if (source != NULL) { + CFStringRef state = CFDictionaryGetValue(source, CFSTR(kIOPSPowerSourceStateKey)); + if (state != NULL) { + if (CFStringCompare(state, CFSTR(kIOPSACPowerValue), 0) == kCFCompareEqualTo) { + result = 2; + } else if (CFStringCompare(state, CFSTR(kIOPSBatteryPowerValue), 0) == kCFCompareEqualTo) { + result = 1; + } + } + int current = 0; + int maximum = 0; + CFNumberRef currentValue = CFDictionaryGetValue(source, CFSTR(kIOPSCurrentCapacityKey)); + CFNumberRef maximumValue = CFDictionaryGetValue(source, CFSTR(kIOPSMaxCapacityKey)); + if (currentValue != NULL) { + CFNumberGetValue(currentValue, kCFNumberIntType, ¤t); + } + if (maximumValue != NULL) { + CFNumberGetValue(maximumValue, kCFNumberIntType, &maximum); + } + if (maximum > 0) { + *level = current * 100 / maximum; + } + } + } + CFRelease(sources); + CFRelease(info); + return result; +} + +#endif diff --git a/service/powerreport/device_stub.go b/service/powerreport/device_stub.go new file mode 100644 index 00000000..dfde18f6 --- /dev/null +++ b/service/powerreport/device_stub.go @@ -0,0 +1,7 @@ +//go:build !darwin || !cgo + +package powerreport + +func readDeviceState() (deviceState, bool) { + return deviceState{}, false +} diff --git a/service/powerreport/record.go b/service/powerreport/record.go index d0e3220c..94e37aa0 100644 --- a/service/powerreport/record.go +++ b/service/powerreport/record.go @@ -64,10 +64,30 @@ type timelineRow struct { DNSQueries uint64 `json:"dnsQueries,omitempty"` ConnectionsOpened uint64 `json:"connectionsOpened,omitempty"` InterfacePackets map[string]uint64 `json:"interfacePackets,omitempty"` + InterfaceBytes map[string]uint64 `json:"interfaceBytes,omitempty"` + Traffic trafficBreakdown `json:"traffic,omitempty"` + DNSDomains map[string]uint64 `json:"dnsDomains,omitempty"` NetworkType string `json:"network,omitempty"` NetworkPathUpdates uint64 `json:"pathUpdates,omitempty"` } +type trafficBreakdown map[TrafficKind]map[string]*trafficDelta + +type trafficDelta struct { + In uint64 `json:"in,omitempty"` + Out uint64 `json:"out,omitempty"` +} + +type trafficKey struct { + kind TrafficKind + tag string +} + +type trafficBytes struct { + inBytes uint64 + outBytes uint64 +} + type qosBreakdown struct { DefaultMS int64 `json:"default,omitempty"` MaintenanceMS int64 `json:"maintenance,omitempty"` @@ -79,9 +99,14 @@ type qosBreakdown struct { } const ( - eventTypeBreak = "break" - eventTypeNetwork = "network" - eventTypePath = "path" + eventTypeBreak = "break" + eventTypeNetwork = "network" + eventTypePath = "path" + eventTypeDevice = "device" + eventTypeDevicePause = "device-pause" + eventTypeDeviceWake = "device-wake" + eventTypeNetworkPause = "network-pause" + eventTypeNetworkWake = "network-wake" ) type eventRecord struct { @@ -93,6 +118,14 @@ type eventRecord struct { NetworkType string `json:"network,omitempty"` NetworkPath string `json:"path,omitempty"` By *Attribution `json:"by,omitempty"` + Device *deviceState `json:"device,omitempty"` +} + +type deviceState struct { + PowerSource string `json:"powerSource,omitempty"` + BatteryLevel int `json:"batteryLevel,omitempty"` + LowPowerMode bool `json:"lowPowerMode,omitempty"` + ThermalState string `json:"thermalState,omitempty"` } type systemUsage struct { @@ -116,6 +149,8 @@ type systemUsage struct { } type interfaceCounters struct { - inPackets uint32 - outPackets uint32 + inPackets uint64 + outPackets uint64 + inBytes uint64 + outBytes uint64 } diff --git a/service/powerreport/recorder.go b/service/powerreport/recorder.go index 5cb2767a..920464aa 100644 --- a/service/powerreport/recorder.go +++ b/service/powerreport/recorder.go @@ -1,11 +1,13 @@ package powerreport import ( + "cmp" "encoding/json" "os" "path/filepath" "runtime/metrics" "runtime/pprof" + "slices" "sync" "sync/atomic" "time" @@ -13,6 +15,7 @@ import ( E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" "github.com/sagernet/sing/common/memory" + "github.com/sagernet/sing/service/pause" ) const ( @@ -34,6 +37,11 @@ const ( rowCapacity = 4096 eventCapacity = 8192 + + maxDNSDomains = 512 + maxRowDNSDomains = 8 + maxProfiles = 3 + profileMinInterval = 2 * time.Minute ) type Options struct { @@ -74,16 +82,26 @@ type Recorder struct { connectionsOpened atomic.Uint64 networkPathUpdates atomic.Uint64 - access sync.Mutex - networkType string - networkPath string - rows []timelineRow - events []eventRecord - previous previousSample - lastFlushAt time.Time - started bool - closed bool - metricsSamples []metrics.Sample + trafficAccess sync.RWMutex + traffic map[trafficKey]*TrafficCounter + + dnsAccess sync.Mutex + dnsDomains map[string]uint64 + + access sync.Mutex + networkType string + networkPath string + rows []timelineRow + events []eventRecord + previous previousSample + lastFlushAt time.Time + started bool + closed bool + metricsSamples []metrics.Sample + lastDeviceState deviceState + deviceStateValid bool + profiles []profileEntry + lastProfileAt time.Time done chan struct{} workerDone chan struct{} @@ -108,6 +126,17 @@ type previousSample struct { dnsQueries uint64 connectionsOpened uint64 networkPathUpdates uint64 + traffic map[trafficKey]trafficBytes +} + +type profileEntry struct { + rate float64 + path string +} + +type profileRequest struct { + writePath string + removePath string } func NewRecorder(options Options) *Recorder { @@ -144,6 +173,8 @@ func NewRecorder(options Options) *Recorder { fallbackInterval: fallbackInterval, baseTime: time.Now(), notify: make(chan struct{}, 1), + traffic: make(map[trafficKey]*TrafficCounter), + dnsDomains: make(map[string]uint64), metricsSamples: []metrics.Sample{ {Name: "/cpu/classes/gc/total:cpu-seconds"}, {Name: "/sched/goroutines:goroutines"}, @@ -181,6 +212,7 @@ func (r *Recorder) Start() error { r.lastSampleAt.Store(int64(now.Sub(r.baseTime))) r.lastFlushAt = now r.started = true + r.updateDeviceStateLocked(now) go r.worker() return nil } @@ -248,8 +280,30 @@ func (r *Recorder) Touch(direction Direction, size int, by *Attribution) { } } -func (r *Recorder) CountDNSQuery() { +func (r *Recorder) CountDNSQuery(domain string) { r.dnsQueries.Add(1) + if domain == "" { + return + } + r.dnsAccess.Lock() + count, found := r.dnsDomains[domain] + if found || len(r.dnsDomains) < maxDNSDomains { + r.dnsDomains[domain] = count + 1 + } + r.dnsAccess.Unlock() +} + +func (r *Recorder) RecordPauseEvent(event int) { + switch event { + case pause.EventDevicePaused: + r.RecordPlatformEvent(eventTypeDevicePause) + case pause.EventDeviceWake: + r.RecordPlatformEvent(eventTypeDeviceWake) + case pause.EventNetworkPause: + r.RecordPlatformEvent(eventTypeNetworkPause) + case pause.EventNetworkWake: + r.RecordPlatformEvent(eventTypeNetworkWake) + } } func (r *Recorder) CountConnectionOpened() { @@ -332,19 +386,24 @@ func (r *Recorder) worker() { func (r *Recorder) process() { now := time.Now() r.access.Lock() - defer r.access.Unlock() if !r.started || r.closed { + r.access.Unlock() return } r.consumeBreakLocked() + var request *profileRequest nowNano := int64(now.Sub(r.baseTime)) if nowNano-r.lastSampleAt.Load() >= r.sampleNano { r.lastSampleAt.Store(nowNano) - r.sampleLocked(now) + request = r.sampleLocked(now) } if now.Sub(r.lastFlushAt) >= r.flushInterval || len(r.rows) >= rowCapacity || len(r.events) >= eventCapacity { r.flushLocked(now) } + r.access.Unlock() + if request != nil { + r.writeSampleProfile(request) + } } func (r *Recorder) consumeBreakLocked() { @@ -374,11 +433,12 @@ func (r *Recorder) resetPreviousLocked(now time.Time) { dnsQueries: r.dnsQueries.Load(), connectionsOpened: r.connectionsOpened.Load(), networkPathUpdates: r.networkPathUpdates.Load(), + traffic: r.snapshotTrafficLocked(), } r.previous.absoluteTime, r.previous.continuousTime = readClocks() } -func (r *Recorder) sampleLocked(now time.Time) { +func (r *Recorder) sampleLocked(now time.Time) *profileRequest { previous := r.previous r.resetPreviousLocked(now) current := &r.previous @@ -391,7 +451,9 @@ func (r *Recorder) sampleLocked(now time.Time) { GoMemoryBytes: r.metricsSamples[3].Value.Uint64(), GoHeapLiveBytes: r.metricsSamples[4].Value.Uint64(), DNSQueries: current.dnsQueries - previous.dnsQueries, + DNSDomains: r.collectDNSDomains(), ConnectionsOpened: current.connectionsOpened - previous.connectionsOpened, + Traffic: trafficDeltas(current.traffic, previous.traffic), NetworkType: r.networkType, NetworkPathUpdates: current.networkPathUpdates - previous.networkPathUpdates, } @@ -433,21 +495,152 @@ func (r *Recorder) sampleLocked(now time.Time) { } if len(current.interfaces) > 0 && len(previous.interfaces) > 0 { interfacePackets := make(map[string]uint64) + interfaceBytes := make(map[string]uint64) for name, counters := range current.interfaces { previousCounters, found := previous.interfaces[name] if !found { continue } - delta := uint64(counters.inPackets-previousCounters.inPackets) + uint64(counters.outPackets-previousCounters.outPackets) - if delta > 0 { - interfacePackets[name] = delta + packets := counters.inPackets - previousCounters.inPackets + counters.outPackets - previousCounters.outPackets + if packets > 0 { + interfacePackets[name] = packets + } + bytesDelta := counters.inBytes - previousCounters.inBytes + counters.outBytes - previousCounters.outBytes + if bytesDelta > 0 { + interfaceBytes[name] = bytesDelta } } if len(interfacePackets) > 0 { row.InterfacePackets = interfacePackets } + if len(interfaceBytes) > 0 { + row.InterfaceBytes = interfaceBytes + } } + r.updateDeviceStateLocked(now) r.rows = append(r.rows, row) + return r.requestProfileLocked(now, &row, now.Sub(previous.at)) +} + +func (r *Recorder) collectDNSDomains() map[string]uint64 { + r.dnsAccess.Lock() + defer r.dnsAccess.Unlock() + if len(r.dnsDomains) == 0 { + return nil + } + type domainCount struct { + domain string + count uint64 + } + counts := make([]domainCount, 0, len(r.dnsDomains)) + for domain, count := range r.dnsDomains { + counts = append(counts, domainCount{domain, count}) + } + clear(r.dnsDomains) + slices.SortFunc(counts, func(a domainCount, b domainCount) int { + return cmp.Compare(b.count, a.count) + }) + if len(counts) > maxRowDNSDomains { + counts = counts[:maxRowDNSDomains] + } + result := make(map[string]uint64, len(counts)) + for _, entry := range counts { + result[entry.domain] = entry.count + } + return result +} + +func trafficDeltas(current map[trafficKey]trafficBytes, previous map[trafficKey]trafficBytes) trafficBreakdown { + var result trafficBreakdown + for key, currentBytes := range current { + previousBytes := previous[key] + delta := trafficDelta{ + In: currentBytes.inBytes - previousBytes.inBytes, + Out: currentBytes.outBytes - previousBytes.outBytes, + } + if delta == (trafficDelta{}) { + continue + } + if result == nil { + result = make(trafficBreakdown) + } + byTag := result[key.kind] + if byTag == nil { + byTag = make(map[string]*trafficDelta) + result[key.kind] = byTag + } + byTag[key.tag] = &delta + } + return result +} + +func (r *Recorder) updateDeviceStateLocked(now time.Time) { + state, loaded := readDeviceState() + if !loaded { + return + } + if r.deviceStateValid && r.lastDeviceState == state { + return + } + r.lastDeviceState = state + r.deviceStateValid = true + r.events = append(r.events, eventRecord{ + Type: eventTypeDevice, + At: now.UTC().Format(time.RFC3339), + Device: &state, + }) +} + +func (r *Recorder) requestProfileLocked(now time.Time, row *timelineRow, duration time.Duration) *profileRequest { + if r.closed || duration <= 0 { + return nil + } + work := float64(row.EnergyNanojoules) + if work == 0 { + work = float64(row.CPUUserMS + row.CPUSystemMS) + } + if work == 0 { + return nil + } + rate := work / duration.Seconds() + lowestIndex := -1 + if len(r.profiles) >= maxProfiles { + lowestIndex = 0 + for index := 1; index < len(r.profiles); index++ { + if r.profiles[index].rate < r.profiles[lowestIndex].rate { + lowestIndex = index + } + } + if rate <= r.profiles[lowestIndex].rate { + return nil + } + } + if !r.lastProfileAt.IsZero() && now.Sub(r.lastProfileAt) < profileMinInterval { + return nil + } + request := &profileRequest{ + writePath: filepath.Join(r.draftPath, "goroutine-"+now.UTC().Format("150405")+".pb"), + } + if lowestIndex >= 0 { + request.removePath = r.profiles[lowestIndex].path + r.profiles = slices.Delete(r.profiles, lowestIndex, lowestIndex+1) + } + r.profiles = append(r.profiles, profileEntry{rate: rate, path: request.writePath}) + r.lastProfileAt = now + return request +} + +func (r *Recorder) writeSampleProfile(request *profileRequest) { + if request.removePath != "" { + os.Remove(request.removePath) + } + file, err := os.OpenFile(request.writePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o666) + if err != nil { + return + } + defer file.Close() + r.chown(request.writePath) + pprof.Lookup("goroutine").WriteTo(file, 0) } func (r *Recorder) chown(path string) { diff --git a/service/powerreport/sysstats_darwin.go b/service/powerreport/sysstats_darwin.go index 632d7440..332e130a 100644 --- a/service/powerreport/sysstats_darwin.go +++ b/service/powerreport/sysstats_darwin.go @@ -1,7 +1,6 @@ package powerreport /* -#include #include #include #include @@ -16,9 +15,11 @@ int proc_pid_rusage(int pid, int flavor, rusage_info_t *buffer); import "C" import ( + "encoding/binary" "os" "strings" "sync" + "syscall" "unsafe" ) @@ -99,25 +100,35 @@ func readClocks() (absoluteTime int64, continuousTime int64) { } func readInterfaceCounters() map[string]interfaceCounters { - var list *C.struct_ifaddrs - if C.getifaddrs(&list) != 0 { + table, err := syscall.RouteRIB(syscall.NET_RT_IFLIST2, 0) + if err != nil { return nil } - defer C.freeifaddrs(list) + var message C.struct_if_msghdr2 + messageSize := int(unsafe.Sizeof(message)) + messageBytes := unsafe.Slice((*byte)(unsafe.Pointer(&message)), messageSize) result := make(map[string]interfaceCounters) - for entry := list; entry != nil; entry = entry.ifa_next { - if entry.ifa_addr == nil || entry.ifa_addr.sa_family != C.AF_LINK || entry.ifa_data == nil { - continue + for len(table) >= 4 { + messageLength := int(binary.NativeEndian.Uint16(table)) + if messageLength < 4 || messageLength > len(table) { + break } - name := C.GoString(entry.ifa_name) - if !strings.HasPrefix(name, "en") && !strings.HasPrefix(name, "pdp_ip") { - continue - } - data := (*C.struct_if_data)(entry.ifa_data) - result[name] = interfaceCounters{ - inPackets: uint32(data.ifi_ipackets), - outPackets: uint32(data.ifi_opackets), + if table[3] == syscall.RTM_IFINFO2 && messageLength >= messageSize { + copy(messageBytes, table[:messageSize]) + var nameBuffer [C.IF_NAMESIZE]C.char + if C.if_indextoname(C.uint(message.ifm_index), &nameBuffer[0]) != nil { + name := C.GoString(&nameBuffer[0]) + if strings.HasPrefix(name, "en") || strings.HasPrefix(name, "pdp_ip") { + result[name] = interfaceCounters{ + inPackets: uint64(message.ifm_data.ifi_ipackets), + outPackets: uint64(message.ifm_data.ifi_opackets), + inBytes: uint64(message.ifm_data.ifi_ibytes), + outBytes: uint64(message.ifm_data.ifi_obytes), + } + } + } } + table = table[messageLength:] } return result } diff --git a/service/powerreport/sysstats_linux.go b/service/powerreport/sysstats_linux.go index cdc82f14..366d7307 100644 --- a/service/powerreport/sysstats_linux.go +++ b/service/powerreport/sysstats_linux.go @@ -83,9 +83,16 @@ func readInterfaceCounters() map[string]interfaceCounters { if inErr != nil || outErr != nil { continue } + inBytes, inBytesErr := strconv.ParseUint(fields[0], 10, 64) + outBytes, outBytesErr := strconv.ParseUint(fields[8], 10, 64) + if inBytesErr != nil || outBytesErr != nil { + continue + } result[name] = interfaceCounters{ - inPackets: uint32(inPackets), - outPackets: uint32(outPackets), + inPackets: inPackets, + outPackets: outPackets, + inBytes: inBytes, + outBytes: outBytes, } } return result diff --git a/service/powerreport/traffic.go b/service/powerreport/traffic.go new file mode 100644 index 00000000..41c0f3ea --- /dev/null +++ b/service/powerreport/traffic.go @@ -0,0 +1,73 @@ +package powerreport + +import "sync/atomic" + +type TrafficKind string + +const ( + TrafficOutbound TrafficKind = "outbound" + TrafficEndpoint TrafficKind = "endpoint" + TrafficDNSTransport TrafficKind = "dns" +) + +const maxTrafficCounters = 512 + +type TrafficCounter struct { + inBytes atomic.Uint64 + outBytes atomic.Uint64 +} + +func (c *TrafficCounter) CountIn(n int64) { + if c == nil || n <= 0 { + return + } + c.inBytes.Add(uint64(n)) +} + +func (c *TrafficCounter) CountOut(n int64) { + if c == nil || n <= 0 { + return + } + c.outBytes.Add(uint64(n)) +} + +func (r *Recorder) TrafficCounter(kind TrafficKind, tag string) *TrafficCounter { + if r == nil || tag == "" { + return nil + } + key := trafficKey{kind: kind, tag: tag} + r.trafficAccess.RLock() + counter := r.traffic[key] + r.trafficAccess.RUnlock() + if counter != nil { + return counter + } + r.trafficAccess.Lock() + defer r.trafficAccess.Unlock() + counter = r.traffic[key] + if counter != nil { + return counter + } + if len(r.traffic) >= maxTrafficCounters { + return nil + } + counter = new(TrafficCounter) + r.traffic[key] = counter + return counter +} + +func (r *Recorder) snapshotTrafficLocked() map[trafficKey]trafficBytes { + r.trafficAccess.RLock() + defer r.trafficAccess.RUnlock() + if len(r.traffic) == 0 { + return nil + } + snapshot := make(map[trafficKey]trafficBytes, len(r.traffic)) + for key, counter := range r.traffic { + snapshot[key] = trafficBytes{ + inBytes: counter.inBytes.Load(), + outBytes: counter.outBytes.Load(), + } + } + return snapshot +} diff --git a/transport/wireguard/endpoint.go b/transport/wireguard/endpoint.go index 7237c09c..f942851f 100644 --- a/transport/wireguard/endpoint.go +++ b/transport/wireguard/endpoint.go @@ -158,9 +158,12 @@ func (e *Endpoint) Start(postStart bool) error { recorder := powerManager.Recorder() if recorder != nil { attribution := &powerreport.Attribution{Endpoint: e.options.Tag} + counter := recorder.TrafficCounter(powerreport.TrafficEndpoint, e.options.Tag) standardBind.SetIOActivityFuncs(func(size int) { + counter.CountIn(int64(size)) recorder.Touch(powerreport.DirectionInbound, size, attribution) }, func(size int) { + counter.CountOut(int64(size)) recorder.Touch(powerreport.DirectionOutbound, size, attribution) }) } From 07130365d1415c23fca3a2170d52c7c61c56166d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 4 Sep 2026 02:19:31 +0800 Subject: [PATCH 45/66] Improve idle connection management --- adapter/outbound.go | 3 +- adapter/session.go | 14 ++ adapter/v2ray.go | 5 + box.go | 13 +- common/dialer/default.go | 4 + common/urltest/keep_session_quic.go | 13 + common/urltest/keep_session_quic_stub.go | 9 + common/urltest/urltest.go | 8 +- daemon/instance.go | 13 +- dns/transport/conn_pool.go | 31 +++ dns/transport/https.go | 26 +- dns/transport/https_transport.go | 4 + dns/transport/multiplexer.go | 23 +- dns/transport/tcp.go | 13 +- dns/transport/tls.go | 13 +- dns/transport/udp.go | 13 +- experimental/boxdd/server.go | 2 +- experimental/libbox/command_server.go | 11 +- experimental/libbox/power_report.go | 22 +- protocol/hysteria/outbound.go | 16 +- protocol/hysteria2/outbound.go | 16 +- protocol/shadowsocks/outbound.go | 8 +- protocol/snell/outbound.go | 14 +- protocol/ssh/outbound.go | 36 +-- protocol/trojan/outbound.go | 24 +- protocol/tuic/outbound.go | 11 +- protocol/vless/outbound.go | 24 +- protocol/vmess/outbound.go | 24 +- route/reference.go | 88 +++++-- service/powerreport/record.go | 46 +++- service/powerreport/recorder.go | 295 +++++++++++++++++------ service/powerreport/traffic.go | 9 + transport/v2raygrpclite/client.go | 20 +- transport/v2raygrpclite/conn.go | 4 + transport/v2rayhttp/client.go | 22 +- transport/v2rayhttp/conn.go | 14 +- transport/v2rayquic/client.go | 36 ++- 37 files changed, 766 insertions(+), 181 deletions(-) create mode 100644 adapter/session.go create mode 100644 common/urltest/keep_session_quic.go create mode 100644 common/urltest/keep_session_quic_stub.go diff --git a/adapter/outbound.go b/adapter/outbound.go index f688f164..73d93ddb 100644 --- a/adapter/outbound.go +++ b/adapter/outbound.go @@ -51,7 +51,8 @@ type OutboundManager interface { Create(ctx context.Context, router Router, logger log.ContextLogger, tag string, outboundType string, options any) error } -type IdleConnectionCloser interface { +type IdleConnectionKeeper interface { + SetKeepIdleConnections(keep bool) CloseIdleConnections() } diff --git a/adapter/session.go b/adapter/session.go new file mode 100644 index 00000000..4a5f3fdf --- /dev/null +++ b/adapter/session.go @@ -0,0 +1,14 @@ +package adapter + +import "context" + +type keepSessionKey struct{} + +func ContextWithKeepSession(ctx context.Context) context.Context { + return context.WithValue(ctx, (*keepSessionKey)(nil), true) +} + +func KeepSessionFromContext(ctx context.Context) bool { + keep, _ := ctx.Value((*keepSessionKey)(nil)).(bool) + return keep +} diff --git a/adapter/v2ray.go b/adapter/v2ray.go index d9370807..2dfa4102 100644 --- a/adapter/v2ray.go +++ b/adapter/v2ray.go @@ -22,3 +22,8 @@ type V2RayClientTransport interface { DialContext(ctx context.Context) (net.Conn, error) Close() error } + +type V2RayMultiplexClientTransport interface { + V2RayClientTransport + MultiplexEnabled() bool +} diff --git a/box.go b/box.go index 35037766..f1dbc3bf 100644 --- a/box.go +++ b/box.go @@ -60,6 +60,7 @@ type Box struct { dnsRouter *dns.Router connection *route.ConnectionManager router *route.Router + referenceManager *route.ReferenceManager httpClientService adapter.LifecycleService internalService []adapter.LifecycleService done chan struct{} @@ -257,7 +258,8 @@ func New(options Options) (*Box, error) { service.MustRegisterPtr(ctx, clashMode) internalServices = append(internalServices, clashMode) } - internalServices = append(internalServices, route.NewReferenceManager(ctx, logFactory.NewLogger("reference"), options.Options)) + referenceManager := route.NewReferenceManager(ctx, logFactory.NewLogger("reference"), options.Options) + internalServices = append(internalServices, referenceManager) ntpOptions := common.PtrValueOrDefault(options.NTP) var timeService *tls.TimeServiceWrapper if ntpOptions.Enabled { @@ -480,6 +482,7 @@ func New(options Options) (*Box, error) { dnsRouter: dnsRouter, connection: connectionManager, router: router, + referenceManager: referenceManager, httpClientService: httpClientService, createdAt: createdAt, debugOptions: debugOptions, @@ -682,6 +685,14 @@ func (s *Box) Endpoint() adapter.EndpointManager { return s.endpoint } +func (s *Box) CreatedAt() time.Time { + return s.createdAt +} + +func (s *Box) CloseIdleConnections() { + s.referenceManager.CloseIdleConnections() +} + func (s *Box) LogFactory() log.Factory { return s.logFactory } diff --git a/common/dialer/default.go b/common/dialer/default.go index 154835dc..25b264ff 100644 --- a/common/dialer/default.go +++ b/common/dialer/default.go @@ -433,6 +433,8 @@ func (d *DefaultDialer) trackConn(ctx context.Context, destination M.Socksaddr, attribution := d.dialAttribution(ctx, destination) outboundCounter := recorder.TrafficCounter(powerreport.TrafficOutbound, attribution.Outbound) dnsCounter := recorder.TrafficCounter(powerreport.TrafficDNSTransport, attribution.DNS) + outboundCounter.CountDial() + dnsCounter.CountDial() conn = bufio.NewCounterConn(conn, []N.CountFunc{func(n int64) { outboundCounter.CountIn(n) dnsCounter.CountIn(n) @@ -461,6 +463,8 @@ func (d *DefaultDialer) trackPacketConn(ctx context.Context, destination M.Socks attribution := d.dialAttribution(ctx, destination) outboundCounter := recorder.TrafficCounter(powerreport.TrafficOutbound, attribution.Outbound) dnsCounter := recorder.TrafficCounter(powerreport.TrafficDNSTransport, attribution.DNS) + outboundCounter.CountDial() + dnsCounter.CountDial() conn = bufio.NewNetPacketConn(bufio.NewCounterPacketConn(bufio.NewPacketConn(conn), []N.CountFunc{func(n int64) { outboundCounter.CountIn(n) dnsCounter.CountIn(n) diff --git a/common/urltest/keep_session_quic.go b/common/urltest/keep_session_quic.go new file mode 100644 index 00000000..6aebd111 --- /dev/null +++ b/common/urltest/keep_session_quic.go @@ -0,0 +1,13 @@ +//go:build with_quic + +package urltest + +import ( + "context" + + "github.com/sagernet/sing-quic" +) + +func contextWithQUICKeepSession(ctx context.Context) context.Context { + return qtls.ContextWithKeepSession(ctx) +} diff --git a/common/urltest/keep_session_quic_stub.go b/common/urltest/keep_session_quic_stub.go new file mode 100644 index 00000000..d402f906 --- /dev/null +++ b/common/urltest/keep_session_quic_stub.go @@ -0,0 +1,9 @@ +//go:build !with_quic + +package urltest + +import "context" + +func contextWithQUICKeepSession(ctx context.Context) context.Context { + return ctx +} diff --git a/common/urltest/urltest.go b/common/urltest/urltest.go index 36d44957..26efe39c 100644 --- a/common/urltest/urltest.go +++ b/common/urltest/urltest.go @@ -11,6 +11,8 @@ import ( "github.com/sagernet/sing-box/adapter" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-mux" + "github.com/sagernet/sing-snell" "github.com/sagernet/sing/common" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" @@ -81,7 +83,11 @@ func (s *HistoryStorage) Close() error { func URLTest(ctx context.Context, link string, detour N.Dialer) (uint16, error) { multiplexOutbound, isMultiplexOutbound := common.Cast[adapter.OutboundWithMultiplex](detour) if isMultiplexOutbound && multiplexOutbound.MultiplexEnabled() { - _, err := urlTest(ctx, link, detour) + warmContext := adapter.ContextWithKeepSession(ctx) + warmContext = mux.ContextWithKeepSession(warmContext) + warmContext = contextWithQUICKeepSession(warmContext) + warmContext = snell.ContextWithKeepSession(warmContext) + _, err := urlTest(warmContext, link, detour) if err != nil { return 0, err } diff --git a/daemon/instance.go b/daemon/instance.go index 4040693f..e60701dc 100644 --- a/daemon/instance.go +++ b/daemon/instance.go @@ -138,7 +138,7 @@ func (s *StartedService) newInstance(ctx context.Context, profileContent string, i.clashMode = service.PtrFromContext[clashmode.Manager](ctx) i.trafficManager = service.PtrFromContext[trafficcontrol.Manager](ctx) i.pauseManager = service.FromContext[pause.Manager](ctx) - i.registerPauseCallback(ctx) + i.registerPowerReport(ctx) i.cacheFile = service.FromContext[adapter.CacheFile](ctx) i.outboundManager = service.FromContext[adapter.OutboundManager](ctx) i.endpointManager = service.FromContext[adapter.EndpointManager](ctx) @@ -176,9 +176,16 @@ func (i *Instance) Close() error { return i.instance.Close() } -func (i *Instance) registerPauseCallback(ctx context.Context) { +func (i *Instance) registerPowerReport(ctx context.Context) { powerManager := service.FromContext[*powerreport.Manager](ctx) - if i.pauseManager == nil || powerManager == nil { + if powerManager == nil { + return + } + recorder := powerManager.Recorder() + if recorder != nil { + recorder.RecordServiceStart(i.instance.CreatedAt()) + } + if i.pauseManager == nil { return } i.pauseCallback = i.pauseManager.RegisterCallback(func(event int) { diff --git a/dns/transport/conn_pool.go b/dns/transport/conn_pool.go index 0e7a20a8..1069ed7d 100644 --- a/dns/transport/conn_pool.go +++ b/dns/transport/conn_pool.go @@ -47,6 +47,8 @@ type connPoolState[T comparable] struct { shared T hasShared bool sharedClaimed bool + sharedUsers int + sharedWaiters int sharedCtx context.Context sharedCancel context.CancelCauseFunc @@ -122,6 +124,9 @@ func (p *ConnPool[T]) Release(conn T, reuse bool) { p.options.Close(conn, net.ErrClosed) return } + if p.options.Mode == ConnPoolSingle && state.hasShared && state.shared == conn && state.sharedUsers > 0 { + state.sharedUsers-- + } if p.options.Mode == ConnPoolOrdered { if _, idle := state.idleElements[conn]; !idle { state.idleElements[conn] = state.idle.PushBack(conn) @@ -182,6 +187,7 @@ func (p *ConnPool[T]) removeConn(state *connPoolState[T], conn T, cause error) { state.shared = zero state.hasShared = false state.sharedClaimed = false + state.sharedUsers = 0 state.sharedCtx = nil if state.sharedCancel != nil { state.sharedCancel(cause) @@ -196,6 +202,23 @@ func (p *ConnPool[T]) removeConn(state *connPoolState[T], conn T, cause error) { } } +func (p *ConnPool[T]) CloseIdle() { + p.access.Lock() + if p.closed { + p.access.Unlock() + return + } + state := p.state + if p.options.Mode != ConnPoolSingle || !state.hasShared || state.sharedUsers > 0 || state.sharedWaiters > 0 { + p.access.Unlock() + return + } + conn := state.shared + p.removeConn(state, conn, net.ErrClosed) + p.access.Unlock() + p.options.Close(conn, net.ErrClosed) +} + func (p *ConnPool[T]) Reset() { p.access.Lock() if p.closed { @@ -309,6 +332,7 @@ func (p *ConnPool[T]) acquireShared(ctx context.Context, dial func(context.Conte if p.options.IsAlive(conn) { created := !current.sharedClaimed current.sharedClaimed = true + current.sharedUsers++ connCtx := current.sharedCtx p.access.Unlock() return conn, connCtx, created, nil @@ -324,6 +348,7 @@ func (p *ConnPool[T]) acquireShared(ctx context.Context, dial func(context.Conte current.connecting = &connPoolConnect[T]{done: make(chan struct{})} } state := current.connecting + current.sharedWaiters++ p.access.Unlock() if startDial { @@ -338,9 +363,13 @@ func (p *ConnPool[T]) acquireShared(ctx context.Context, dial func(context.Conte } return conn, connCtx, created, err case <-ctx.Done(): + p.access.Lock() + current.sharedWaiters-- + p.access.Unlock() return zero, nil, false, ctx.Err() case <-current.ctx.Done(): p.access.Lock() + current.sharedWaiters-- closed := p.closed p.access.Unlock() if closed { @@ -400,6 +429,7 @@ func (p *ConnPool[T]) collectShared(current *connPoolState[T], state *connPoolCo var zero T p.access.Lock() + current.sharedWaiters-- if state.err != nil { err := state.err p.access.Unlock() @@ -431,6 +461,7 @@ func (p *ConnPool[T]) collectShared(current *connPoolState[T], state *connPoolCo created := !current.sharedClaimed current.sharedClaimed = true + current.sharedUsers++ connCtx := current.sharedCtx p.access.Unlock() return conn, connCtx, created, false, nil diff --git a/dns/transport/https.go b/dns/transport/https.go index 05e9f8c5..582677ec 100644 --- a/dns/transport/https.go +++ b/dns/transport/https.go @@ -34,7 +34,10 @@ import ( const MimeType = "application/dns-message" -var _ adapter.DNSTransport = (*HTTPSTransport)(nil) +var ( + _ adapter.DNSTransport = (*HTTPSTransport)(nil) + _ adapter.IdleConnectionKeeper = (*HTTPSTransport)(nil) +) func RegisterHTTPS(registry *dns.TransportRegistry) { dns.RegisterTransport[option.RemoteHTTPSDNSServerOptions](registry, C.DNSTypeHTTPS, NewHTTPS) @@ -48,6 +51,7 @@ type HTTPSTransport struct { headers http.Header serverAddr M.Socksaddr fallback *atomic.Bool + keepIdle atomic.Bool transportAccess sync.Mutex transport *HTTPSTransportWrapper transportResetAt time.Time @@ -131,7 +135,7 @@ func NewHTTPSRaw( // plain HTTP DoH used by Tailscale fallback.Store(true) } - return &HTTPSTransport{ + transport := &HTTPSTransport{ TransportAdapter: adapter, logger: logger, dialer: dialer, @@ -141,6 +145,8 @@ func NewHTTPSRaw( fallback: fallback, transport: NewHTTPSTransportWrapper(dialer, serverAddr, fallback), } + transport.keepIdle.Store(true) + return transport } func (t *HTTPSTransport) Start(stage adapter.StartStage) error { @@ -161,6 +167,19 @@ func (t *HTTPSTransport) Reset() { t.resetTransportLocked() } +func (t *HTTPSTransport) SetKeepIdleConnections(keep bool) { + t.keepIdle.Store(keep) + if !keep { + t.CloseIdleConnections() + } +} + +func (t *HTTPSTransport) CloseIdleConnections() { + t.transportAccess.Lock() + defer t.transportAccess.Unlock() + t.transport.CloseIdleConnections() +} + func (t *HTTPSTransport) resetTransportLocked() { oldTransport := t.transport t.transport = NewHTTPSTransportWrapper(t.dialer, t.serverAddr, t.fallback) @@ -171,6 +190,9 @@ func (t *HTTPSTransport) resetTransportLocked() { func (t *HTTPSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { startAt := time.Now() response, err := t.exchange(ctx, message) + if !t.keepIdle.Load() { + t.CloseIdleConnections() + } if err != nil { if errors.Is(err, context.DeadlineExceeded) { t.transportAccess.Lock() diff --git a/dns/transport/https_transport.go b/dns/transport/https_transport.go index c4165535..73fafa56 100644 --- a/dns/transport/https_transport.go +++ b/dns/transport/https_transport.go @@ -111,6 +111,10 @@ func (h *HTTPSTransportWrapper) Close() { for _, trackedConn := range connections { trackedConn.Conn.Close() } + h.CloseIdleConnections() +} + +func (h *HTTPSTransportWrapper) CloseIdleConnections() { h.http2Transport.CloseIdleConnections() h.httpTransport.CloseIdleConnections() } diff --git a/dns/transport/multiplexer.go b/dns/transport/multiplexer.go index 6b275ae9..1b6e88c8 100644 --- a/dns/transport/multiplexer.go +++ b/dns/transport/multiplexer.go @@ -47,6 +47,7 @@ type queryMultiplexer struct { reuseState atomic.Int32 demoteFailures atomic.Int32 + keepIdle atomic.Bool probeAccess sync.Mutex probeEpoch uint32 @@ -81,7 +82,7 @@ type pendingQuery struct { } func newQueryMultiplexer(options queryMultiplexerOptions) *queryMultiplexer { - return &queryMultiplexer{ + multiplexer := &queryMultiplexer{ options: options, queries: make(map[uint16]*pendingQuery), connection: NewConnPool(ConnPoolOptions[*multiplexConn]{ @@ -94,6 +95,23 @@ func newQueryMultiplexer(options queryMultiplexerOptions) *queryMultiplexer { }, }), } + multiplexer.keepIdle.Store(true) + return multiplexer +} + +func (m *queryMultiplexer) SetKeepIdleConnections(keep bool) { + m.keepIdle.Store(keep) + m.closeIdleConnection() +} + +func (m *queryMultiplexer) CloseIdleConnections() { + m.connection.CloseIdle() +} + +func (m *queryMultiplexer) closeIdleConnection() { + if !m.keepIdle.Load() { + m.connection.CloseIdle() + } } func (m *queryMultiplexer) Close() error { @@ -307,6 +325,7 @@ func (m *queryMultiplexer) exchangeAsync(ctx context.Context, message *mDNS.Msg, queryId, err := m.register(ctx, connCtx, conn, message, callback, retryReadError && m.options.retryReadError && !created) if err != nil { m.connection.Release(conn, true) + m.closeIdleConnection() callback(nil, err) return } @@ -408,6 +427,7 @@ func (m *queryMultiplexer) complete(queryId uint16, response *mDNS.Msg, err erro response.Id = pending.message.Id } pending.callback(response, err) + m.closeIdleConnection() } func (m *queryMultiplexer) completeContextDone(queryId uint16, ctx context.Context) { @@ -422,6 +442,7 @@ func (m *queryMultiplexer) completeContextDone(queryId uint16, ctx context.Conte m.connection.Release(pending.conn, true) } pending.callback(nil, err) + m.closeIdleConnection() } func (m *queryMultiplexer) recvLoop(conn *multiplexConn) { diff --git a/dns/transport/tcp.go b/dns/transport/tcp.go index d406aeb2..a4b72724 100644 --- a/dns/transport/tcp.go +++ b/dns/transport/tcp.go @@ -22,7 +22,10 @@ import ( mDNS "github.com/miekg/dns" ) -var _ adapter.DNSTransport = (*TCPTransport)(nil) +var ( + _ adapter.DNSTransport = (*TCPTransport)(nil) + _ adapter.IdleConnectionKeeper = (*TCPTransport)(nil) +) func RegisterTCP(registry *dns.TransportRegistry) { dns.RegisterTransport[option.RemoteDNSServerOptions](registry, C.DNSTypeTCP, NewTCP) @@ -91,6 +94,14 @@ func (t *TCPTransport) Reset() { t.multiplexer.Reset() } +func (t *TCPTransport) SetKeepIdleConnections(keep bool) { + t.multiplexer.SetKeepIdleConnections(keep) +} + +func (t *TCPTransport) CloseIdleConnections() { + t.multiplexer.CloseIdleConnections() +} + func (t *TCPTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { return t.multiplexer.Exchange(ctx, message) } diff --git a/dns/transport/tls.go b/dns/transport/tls.go index da1db319..36cd8dd3 100644 --- a/dns/transport/tls.go +++ b/dns/transport/tls.go @@ -20,7 +20,10 @@ import ( mDNS "github.com/miekg/dns" ) -var _ adapter.DNSTransport = (*TLSTransport)(nil) +var ( + _ adapter.DNSTransport = (*TLSTransport)(nil) + _ adapter.IdleConnectionKeeper = (*TLSTransport)(nil) +) func RegisterTLS(registry *dns.TransportRegistry) { dns.RegisterTransport[option.RemoteTLSDNSServerOptions](registry, C.DNSTypeTLS, NewTLS) @@ -97,6 +100,14 @@ func (t *TLSTransport) Reset() { t.multiplexer.Reset() } +func (t *TLSTransport) SetKeepIdleConnections(keep bool) { + t.multiplexer.SetKeepIdleConnections(keep) +} + +func (t *TLSTransport) CloseIdleConnections() { + t.multiplexer.CloseIdleConnections() +} + func (t *TLSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { return t.multiplexer.Exchange(ctx, message) } diff --git a/dns/transport/udp.go b/dns/transport/udp.go index 4d5370c4..ea6deb16 100644 --- a/dns/transport/udp.go +++ b/dns/transport/udp.go @@ -22,7 +22,10 @@ import ( mDNS "github.com/miekg/dns" ) -var _ adapter.DNSTransport = (*UDPTransport)(nil) +var ( + _ adapter.DNSTransport = (*UDPTransport)(nil) + _ adapter.IdleConnectionKeeper = (*UDPTransport)(nil) +) func RegisterUDP(registry *dns.TransportRegistry) { dns.RegisterTransport[option.RemoteDNSServerOptions](registry, C.DNSTypeUDP, NewUDP) @@ -91,6 +94,14 @@ func (t *UDPTransport) Reset() { t.multiplexer.Reset() } +func (t *UDPTransport) SetKeepIdleConnections(keep bool) { + t.multiplexer.SetKeepIdleConnections(keep) +} + +func (t *UDPTransport) CloseIdleConnections() { + t.multiplexer.CloseIdleConnections() +} + func (t *UDPTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { t.updateUDPSize(message) response, err := t.multiplexer.Exchange(ctx, message) diff --git a/experimental/boxdd/server.go b/experimental/boxdd/server.go index ad9df8a2..cb195e01 100644 --- a/experimental/boxdd/server.go +++ b/experimental/boxdd/server.go @@ -223,7 +223,7 @@ func (d *Daemon) startServiceLocked(ctx context.Context, ownerUserID string, con }) d.startedService.SetOOMKillerOptions(options.OOMKillerEnabled, options.OOMKillerDisabled, uint64(options.OOMMemoryLimit)) if options.PowerReportEnabled { - err = d.powerManager.Start(libbox.PowerReportOptions(d.startedService)) + err = d.powerManager.Start(libbox.PowerReportOptions(d.startedService, nil)) if err != nil { d.logger.Warn("start power report recorder: ", err) } diff --git a/experimental/libbox/command_server.go b/experimental/libbox/command_server.go index 5f62d03d..45470b79 100644 --- a/experimental/libbox/command_server.go +++ b/experimental/libbox/command_server.go @@ -94,7 +94,7 @@ func NewCommandServer(handler CommandServerHandler, platformInterface PlatformIn OOMRecorder: oomRecorder, }) if sPowerReportEnabled { - err := powerManager.Start(PowerReportOptions(server.StartedService)) + err := powerManager.Start(PowerReportOptions(server.StartedService, platformInterface)) if err != nil { log.StdLogger().Error(E.Cause(err, "start power report recorder")) } @@ -258,14 +258,19 @@ func (s *CommandServer) NeedFindProcess() bool { func (s *CommandServer) Pause() { recorder := s.powerManager.Recorder() if recorder != nil { - recorder.RecordPlatformEvent("ne-sleep") + recorder.RecordDeviceSleep() } instance := s.StartedService.Instance() if instance == nil || instance.PauseManager() == nil { return } + instance.Box().CloseIdleConnections() instance.PauseManager().DevicePause() if C.IsIos { + // iOS calls wake within seconds of sleep while the device stays locked, so wake is + // ignored and the pause ends one minute after the last sleep instead. Go timers on + // darwin run on CLOCK_UPTIME_RAW, which does not advance while the device sleeps, + // so the minute counts awake time only and never expires inside a sleep. if s.endPauseTimer == nil { s.endPauseTimer = time.AfterFunc(time.Minute, s.endDevicePause) } else { @@ -285,7 +290,7 @@ func (s *CommandServer) endDevicePause() { func (s *CommandServer) Wake() { recorder := s.powerManager.Recorder() if recorder != nil { - recorder.RecordPlatformEvent("ne-wake") + recorder.RecordDeviceWake() } instance := s.StartedService.Instance() if instance == nil || instance.PauseManager() == nil { diff --git a/experimental/libbox/power_report.go b/experimental/libbox/power_report.go index a31afd2d..cad04136 100644 --- a/experimental/libbox/power_report.go +++ b/experimental/libbox/power_report.go @@ -13,17 +13,23 @@ import ( type powerReportMetadata struct { reportMetadata - StartedAt string `json:"startedAt"` + StartedAt string `json:"startedAt"` + IncludeAllNetworks *bool `json:"includeAllNetworks,omitempty"` } -func PowerReportOptions(startedService *daemon.StartedService) powerreport.Options { +func PowerReportOptions(startedService *daemon.StartedService, platformInterface PlatformInterface) powerreport.Options { + metadata := powerReportMetadata{ + reportMetadata: baseReportMetadata(), + StartedAt: time.Now().UTC().Format(time.RFC3339), + } + if platformInterface != nil && platformInterface.UnderNetworkExtension() { + includeAllNetworks := platformInterface.IncludeAllNetworks() + metadata.IncludeAllNetworks = &includeAllNetworks + } return powerreport.Options{ - BasePath: sWorkingPath, - Logger: log.StdLogger(), - Metadata: powerReportMetadata{ - reportMetadata: baseReportMetadata(), - StartedAt: time.Now().UTC().Format(time.RFC3339), - }, + BasePath: sWorkingPath, + Logger: log.StdLogger(), + Metadata: metadata, OwnerCallback: chownReport, LogCallback: func() []byte { return formatLogEntries(startedService.SavedLog()) diff --git a/protocol/hysteria/outbound.go b/protocol/hysteria/outbound.go index 61760d98..a5b7b7c2 100644 --- a/protocol/hysteria/outbound.go +++ b/protocol/hysteria/outbound.go @@ -13,7 +13,6 @@ 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/tuic" "github.com/sagernet/sing-quic/hysteria" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/bufio" @@ -28,9 +27,10 @@ func RegisterOutbound(registry *outbound.Registry) { } var ( - _ adapter.Outbound = (*tuic.Outbound)(nil) - _ adapter.InterfaceUpdateListener = (*tuic.Outbound)(nil) - _ adapter.IdleConnectionCloser = (*Outbound)(nil) + _ adapter.Outbound = (*Outbound)(nil) + _ adapter.InterfaceUpdateListener = (*Outbound)(nil) + _ adapter.IdleConnectionKeeper = (*Outbound)(nil) + _ adapter.OutboundWithMultiplex = (*Outbound)(nil) ) type Outbound struct { @@ -119,6 +119,14 @@ func (h *Outbound) InterfaceUpdated(ctx context.Context) { h.client.CloseWithError(E.New("network changed")) } +func (h *Outbound) MultiplexEnabled() bool { + return true +} + +func (h *Outbound) SetKeepIdleConnections(keep bool) { + h.client.SetKeepIdleConnections(keep) +} + func (h *Outbound) CloseIdleConnections() { h.client.CloseIdleConnections() } diff --git a/protocol/hysteria2/outbound.go b/protocol/hysteria2/outbound.go index fd142e5d..398ee66b 100644 --- a/protocol/hysteria2/outbound.go +++ b/protocol/hysteria2/outbound.go @@ -16,7 +16,6 @@ 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/tuic" qtls "github.com/sagernet/sing-quic" "github.com/sagernet/sing-quic/hysteria" "github.com/sagernet/sing-quic/hysteria2" @@ -35,9 +34,10 @@ func RegisterOutbound(registry *outbound.Registry) { } var ( - _ adapter.Outbound = (*tuic.Outbound)(nil) - _ adapter.InterfaceUpdateListener = (*tuic.Outbound)(nil) - _ adapter.IdleConnectionCloser = (*Outbound)(nil) + _ adapter.Outbound = (*Outbound)(nil) + _ adapter.InterfaceUpdateListener = (*Outbound)(nil) + _ adapter.IdleConnectionKeeper = (*Outbound)(nil) + _ adapter.OutboundWithMultiplex = (*Outbound)(nil) ) type Outbound struct { @@ -211,6 +211,14 @@ func (h *Outbound) InterfaceUpdated(ctx context.Context) { h.client.CloseWithError(E.New("network changed")) } +func (h *Outbound) MultiplexEnabled() bool { + return true +} + +func (h *Outbound) SetKeepIdleConnections(keep bool) { + h.client.SetKeepIdleConnections(keep) +} + func (h *Outbound) CloseIdleConnections() { h.client.CloseIdleConnections() } diff --git a/protocol/shadowsocks/outbound.go b/protocol/shadowsocks/outbound.go index 33532538..2ee20031 100644 --- a/protocol/shadowsocks/outbound.go +++ b/protocol/shadowsocks/outbound.go @@ -29,7 +29,7 @@ func RegisterOutbound(registry *outbound.Registry) { var ( _ adapter.OutboundWithMultiplex = (*Outbound)(nil) _ adapter.InterfaceUpdateListener = (*Outbound)(nil) - _ adapter.IdleConnectionCloser = (*Outbound)(nil) + _ adapter.IdleConnectionKeeper = (*Outbound)(nil) ) type Outbound struct { @@ -140,6 +140,12 @@ func (h *Outbound) InterfaceUpdated(ctx context.Context) { } } +func (h *Outbound) SetKeepIdleConnections(keep bool) { + if h.multiplexDialer != nil { + h.multiplexDialer.SetKeepIdleConnections(keep) + } +} + func (h *Outbound) CloseIdleConnections() { if h.multiplexDialer != nil { h.multiplexDialer.CloseIdleConnections() diff --git a/protocol/snell/outbound.go b/protocol/snell/outbound.go index 90ca6469..71ab1219 100644 --- a/protocol/snell/outbound.go +++ b/protocol/snell/outbound.go @@ -30,17 +30,20 @@ type Outbound struct { dialer N.Dialer client snellClient serverAddr M.Socksaddr + reuse bool } var ( _ adapter.InterfaceUpdateListener = (*Outbound)(nil) - _ adapter.IdleConnectionCloser = (*Outbound)(nil) + _ adapter.IdleConnectionKeeper = (*Outbound)(nil) + _ adapter.OutboundWithMultiplex = (*Outbound)(nil) ) type snellClient interface { snellprotocol.Method DialContext(ctx context.Context, destination M.Socksaddr) (net.Conn, error) Reset() + SetKeepIdleConnections(keep bool) CloseIdleConnections() Close() error } @@ -96,6 +99,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL dialer: outboundDialer, client: client, serverAddr: serverAddr, + reuse: options.Reuse, } return outbound, nil } @@ -147,6 +151,14 @@ func (h *Outbound) InterfaceUpdated(ctx context.Context) { h.client.Reset() } +func (h *Outbound) MultiplexEnabled() bool { + return h.reuse +} + +func (h *Outbound) SetKeepIdleConnections(keep bool) { + h.client.SetKeepIdleConnections(keep) +} + func (h *Outbound) CloseIdleConnections() { h.client.CloseIdleConnections() } diff --git a/protocol/ssh/outbound.go b/protocol/ssh/outbound.go index 0033e92b..7d82a3e1 100644 --- a/protocol/ssh/outbound.go +++ b/protocol/ssh/outbound.go @@ -34,7 +34,8 @@ func RegisterOutbound(registry *outbound.Registry) { var ( _ adapter.InterfaceUpdateListener = (*Outbound)(nil) - _ adapter.IdleConnectionCloser = (*Outbound)(nil) + _ adapter.IdleConnectionKeeper = (*Outbound)(nil) + _ adapter.OutboundWithMultiplex = (*Outbound)(nil) ) type Outbound struct { @@ -54,7 +55,7 @@ type Outbound struct { clientConn net.Conn client *ssh.Client streams int - draining bool + closeIdle bool } func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SSHOutboundOptions) (adapter.Outbound, error) { @@ -137,7 +138,6 @@ func (s *Outbound) connect(ctx context.Context) (client *ssh.Client, err error) if s.client != nil { s.streams++ - s.draining = false return s.client, nil } @@ -197,7 +197,6 @@ func (s *Outbound) connect(ctx context.Context) (client *ssh.Client, err error) s.clientConn = conn s.client = client s.streams = 1 - s.draining = false go func() { client.Wait() @@ -217,14 +216,22 @@ func (s *Outbound) InterfaceUpdated(ctx context.Context) { common.Close(s.clientConn) } +func (s *Outbound) MultiplexEnabled() bool { + return true +} + +func (s *Outbound) SetKeepIdleConnections(keep bool) { + s.clientAccess.Lock() + s.closeIdle = !keep + s.clientAccess.Unlock() + if !keep { + s.CloseIdleConnections() + } +} + func (s *Outbound) CloseIdleConnections() { s.clientAccess.Lock() - if s.client == nil { - s.clientAccess.Unlock() - return - } - s.draining = true - if s.streams > 0 { + if s.client == nil || s.streams > 0 { s.clientAccess.Unlock() return } @@ -235,14 +242,14 @@ func (s *Outbound) CloseIdleConnections() { common.Close(clientConn) } -func (s *Outbound) releaseStream(client *ssh.Client) { +func (s *Outbound) releaseStream(client *ssh.Client, keepSession bool) { s.clientAccess.Lock() if s.client != client { s.clientAccess.Unlock() return } s.streams-- - if !s.draining || s.streams > 0 { + if !s.closeIdle || keepSession || s.streams > 0 { s.clientAccess.Unlock() return } @@ -264,10 +271,11 @@ func (s *Outbound) DialContext(ctx context.Context, network string, destination } conn, err := client.Dial(network, destination.String()) if err != nil { - s.releaseStream(client) + s.releaseStream(client, false) return nil, err } - return &chanConnWrapper{Conn: conn, onClose: func() { s.releaseStream(client) }}, nil + keepSession := adapter.KeepSessionFromContext(ctx) + return &chanConnWrapper{Conn: conn, onClose: func() { s.releaseStream(client, keepSession) }}, nil } func (s *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { diff --git a/protocol/trojan/outbound.go b/protocol/trojan/outbound.go index 591d44a9..2273c4b5 100644 --- a/protocol/trojan/outbound.go +++ b/protocol/trojan/outbound.go @@ -29,7 +29,7 @@ func RegisterOutbound(registry *outbound.Registry) { var ( _ adapter.OutboundWithMultiplex = (*Outbound)(nil) _ adapter.InterfaceUpdateListener = (*Outbound)(nil) - _ adapter.IdleConnectionCloser = (*Outbound)(nil) + _ adapter.IdleConnectionKeeper = (*Outbound)(nil) ) type Outbound struct { @@ -114,7 +114,11 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n } func (h *Outbound) MultiplexEnabled() bool { - return h.multiplexDialer != nil + if h.multiplexDialer != nil { + return true + } + multiplexTransport, isMultiplexTransport := h.transport.(adapter.V2RayMultiplexClientTransport) + return isMultiplexTransport && multiplexTransport.MultiplexEnabled() } func (h *Outbound) InterfaceUpdated(ctx context.Context) { @@ -126,10 +130,20 @@ func (h *Outbound) InterfaceUpdated(ctx context.Context) { } } +func (h *Outbound) SetKeepIdleConnections(keep bool) { + transportKeeper, isTransportKeeper := h.transport.(adapter.IdleConnectionKeeper) + if isTransportKeeper { + transportKeeper.SetKeepIdleConnections(keep) + } + if h.multiplexDialer != nil { + h.multiplexDialer.SetKeepIdleConnections(keep) + } +} + func (h *Outbound) CloseIdleConnections() { - transportCloser, isTransportCloser := h.transport.(adapter.IdleConnectionCloser) - if isTransportCloser { - transportCloser.CloseIdleConnections() + transportKeeper, isTransportKeeper := h.transport.(adapter.IdleConnectionKeeper) + if isTransportKeeper { + transportKeeper.CloseIdleConnections() } if h.multiplexDialer != nil { h.multiplexDialer.CloseIdleConnections() diff --git a/protocol/tuic/outbound.go b/protocol/tuic/outbound.go index 615d41fe..db7a5658 100644 --- a/protocol/tuic/outbound.go +++ b/protocol/tuic/outbound.go @@ -32,7 +32,8 @@ func RegisterOutbound(registry *outbound.Registry) { var ( _ adapter.InterfaceUpdateListener = (*Outbound)(nil) - _ adapter.IdleConnectionCloser = (*Outbound)(nil) + _ adapter.IdleConnectionKeeper = (*Outbound)(nil) + _ adapter.OutboundWithMultiplex = (*Outbound)(nil) ) type Outbound struct { @@ -148,6 +149,14 @@ func (h *Outbound) InterfaceUpdated(ctx context.Context) { _ = h.client.CloseWithError(E.New("network changed")) } +func (h *Outbound) MultiplexEnabled() bool { + return true +} + +func (h *Outbound) SetKeepIdleConnections(keep bool) { + h.client.SetKeepIdleConnections(keep) +} + func (h *Outbound) CloseIdleConnections() { h.client.CloseIdleConnections() } diff --git a/protocol/vless/outbound.go b/protocol/vless/outbound.go index 8be11db2..f837ac04 100644 --- a/protocol/vless/outbound.go +++ b/protocol/vless/outbound.go @@ -30,7 +30,7 @@ func RegisterOutbound(registry *outbound.Registry) { var ( _ adapter.OutboundWithMultiplex = (*Outbound)(nil) _ adapter.InterfaceUpdateListener = (*Outbound)(nil) - _ adapter.IdleConnectionCloser = (*Outbound)(nil) + _ adapter.IdleConnectionKeeper = (*Outbound)(nil) ) type Outbound struct { @@ -134,7 +134,11 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n } func (h *Outbound) MultiplexEnabled() bool { - return h.multiplexDialer != nil + if h.multiplexDialer != nil { + return true + } + multiplexTransport, isMultiplexTransport := h.transport.(adapter.V2RayMultiplexClientTransport) + return isMultiplexTransport && multiplexTransport.MultiplexEnabled() } func (h *Outbound) InterfaceUpdated(ctx context.Context) { @@ -146,10 +150,20 @@ func (h *Outbound) InterfaceUpdated(ctx context.Context) { } } +func (h *Outbound) SetKeepIdleConnections(keep bool) { + transportKeeper, isTransportKeeper := h.transport.(adapter.IdleConnectionKeeper) + if isTransportKeeper { + transportKeeper.SetKeepIdleConnections(keep) + } + if h.multiplexDialer != nil { + h.multiplexDialer.SetKeepIdleConnections(keep) + } +} + func (h *Outbound) CloseIdleConnections() { - transportCloser, isTransportCloser := h.transport.(adapter.IdleConnectionCloser) - if isTransportCloser { - transportCloser.CloseIdleConnections() + transportKeeper, isTransportKeeper := h.transport.(adapter.IdleConnectionKeeper) + if isTransportKeeper { + transportKeeper.CloseIdleConnections() } if h.multiplexDialer != nil { h.multiplexDialer.CloseIdleConnections() diff --git a/protocol/vmess/outbound.go b/protocol/vmess/outbound.go index 4ac274b5..14597c17 100644 --- a/protocol/vmess/outbound.go +++ b/protocol/vmess/outbound.go @@ -30,7 +30,7 @@ func RegisterOutbound(registry *outbound.Registry) { var ( _ adapter.OutboundWithMultiplex = (*Outbound)(nil) _ adapter.InterfaceUpdateListener = (*Outbound)(nil) - _ adapter.IdleConnectionCloser = (*Outbound)(nil) + _ adapter.IdleConnectionKeeper = (*Outbound)(nil) ) type Outbound struct { @@ -112,7 +112,11 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL } func (h *Outbound) MultiplexEnabled() bool { - return h.multiplexDialer != nil + if h.multiplexDialer != nil { + return true + } + multiplexTransport, isMultiplexTransport := h.transport.(adapter.V2RayMultiplexClientTransport) + return isMultiplexTransport && multiplexTransport.MultiplexEnabled() } func (h *Outbound) InterfaceUpdated(ctx context.Context) { @@ -124,10 +128,20 @@ func (h *Outbound) InterfaceUpdated(ctx context.Context) { } } +func (h *Outbound) SetKeepIdleConnections(keep bool) { + transportKeeper, isTransportKeeper := h.transport.(adapter.IdleConnectionKeeper) + if isTransportKeeper { + transportKeeper.SetKeepIdleConnections(keep) + } + if h.multiplexDialer != nil { + h.multiplexDialer.SetKeepIdleConnections(keep) + } +} + func (h *Outbound) CloseIdleConnections() { - transportCloser, isTransportCloser := h.transport.(adapter.IdleConnectionCloser) - if isTransportCloser { - transportCloser.CloseIdleConnections() + transportKeeper, isTransportKeeper := h.transport.(adapter.IdleConnectionKeeper) + if isTransportKeeper { + transportKeeper.CloseIdleConnections() } if h.multiplexDialer != nil { h.multiplexDialer.CloseIdleConnections() diff --git a/route/reference.go b/route/reference.go index e2f5ba41..708efe5b 100644 --- a/route/reference.go +++ b/route/reference.go @@ -23,7 +23,7 @@ type ReferenceManager struct { staticOutbounds []string staticTransports []string subscriber *observable.Subscriber[struct{}] - unreferencedOutbounds map[string]bool + keepIdle map[any]bool unreferencedTransports map[string]bool } @@ -201,28 +201,40 @@ func (m *ReferenceManager) update() { } } - unreferencedOutbounds := make(map[string]bool) + keepIdle := make(map[any]bool) for _, outbound := range outboundManager.Outbounds() { - tag := outbound.Tag() - if referencedOutbounds[tag] { + keeper, isKeeper := outbound.(adapter.IdleConnectionKeeper) + if !isKeeper { continue } - unreferencedOutbounds[tag] = true - closer, isCloser := outbound.(adapter.IdleConnectionCloser) - if !isCloser { - continue - } - if !m.unreferencedOutbounds[tag] { - m.logger.Debug("outbound/", outbound.Type(), "[", tag, "] is unreferenced, closing idle connections") - } - closer.CloseIdleConnections() + m.applyKeepIdle(keepIdle, idleTarget{ + value: outbound, + keeper: keeper, + kind: "outbound/", + action: "closing idle connections", + typeName: outbound.Type(), + tag: outbound.Tag(), + keep: referencedOutbounds[outbound.Tag()], + }) } - m.unreferencedOutbounds = unreferencedOutbounds - unreferencedTransports := make(map[string]bool) for _, transport := range transportManager.Transports() { tag := transport.Tag() - if referencedTransports[tag] { + referenced := referencedTransports[tag] + keeper, isKeeper := transport.(adapter.IdleConnectionKeeper) + if isKeeper { + m.applyKeepIdle(keepIdle, idleTarget{ + value: transport, + keeper: keeper, + kind: "dns/", + action: "closing idle connections", + typeName: transport.Type(), + tag: tag, + keep: referenced, + }) + continue + } + if referenced { continue } unreferencedTransports[tag] = true @@ -231,5 +243,49 @@ func (m *ReferenceManager) update() { transport.Reset() } } + m.keepIdle = keepIdle m.unreferencedTransports = unreferencedTransports } + +type idleKeeper interface { + SetKeepIdleConnections(keep bool) +} + +type idleTarget struct { + value any + keeper idleKeeper + kind string + action string + typeName string + tag string + keep bool +} + +func (m *ReferenceManager) applyKeepIdle(keepIdle map[any]bool, target idleTarget) { + keepIdle[target.value] = target.keep + previous, tracked := m.keepIdle[target.value] + if tracked && previous == target.keep { + return + } + if !target.keep { + m.logger.Debug(target.kind, target.typeName, "[", target.tag, "] is unreferenced, ", target.action) + } + target.keeper.SetKeepIdleConnections(target.keep) +} + +func (m *ReferenceManager) CloseIdleConnections() { + outboundManager := service.FromContext[adapter.OutboundManager](m.ctx) + transportManager := service.FromContext[adapter.DNSTransportManager](m.ctx) + for _, outbound := range outboundManager.Outbounds() { + keeper, isKeeper := outbound.(adapter.IdleConnectionKeeper) + if isKeeper { + keeper.CloseIdleConnections() + } + } + for _, transport := range transportManager.Transports() { + keeper, isKeeper := transport.(adapter.IdleConnectionKeeper) + if isKeeper { + keeper.CloseIdleConnections() + } + } +} diff --git a/service/powerreport/record.go b/service/powerreport/record.go index 94e37aa0..d8e555ef 100644 --- a/service/powerreport/record.go +++ b/service/powerreport/record.go @@ -45,6 +45,8 @@ type ProcessAttribution struct { type timelineRow struct { From string `json:"from"` To string `json:"to"` + MonoMS int64 `json:"monoMS"` + AwakeMS int64 `json:"awakeMS,omitempty"` CPUUserMS int64 `json:"cpuUserMS,omitempty"` CPUSystemMS int64 `json:"cpuSystemMS,omitempty"` CPUPerformanceMS int64 `json:"cpuPerformanceMS,omitempty"` @@ -74,8 +76,9 @@ type timelineRow struct { type trafficBreakdown map[TrafficKind]map[string]*trafficDelta type trafficDelta struct { - In uint64 `json:"in,omitempty"` - Out uint64 `json:"out,omitempty"` + In uint64 `json:"in,omitempty"` + Out uint64 `json:"out,omitempty"` + Dials uint64 `json:"dials,omitempty"` } type trafficKey struct { @@ -86,6 +89,7 @@ type trafficKey struct { type trafficBytes struct { inBytes uint64 outBytes uint64 + dials uint64 } type qosBreakdown struct { @@ -107,18 +111,38 @@ const ( eventTypeDeviceWake = "device-wake" eventTypeNetworkPause = "network-pause" eventTypeNetworkWake = "network-wake" + eventTypeSleep = "ne-sleep" + eventTypeWake = "ne-wake" + eventTypeService = "service" ) type eventRecord struct { - Type string `json:"t"` - At string `json:"at"` - IdleMS int64 `json:"idleMS,omitempty"` - Direction string `json:"direction,omitempty"` - Size int `json:"size,omitempty"` - NetworkType string `json:"network,omitempty"` - NetworkPath string `json:"path,omitempty"` - By *Attribution `json:"by,omitempty"` - Device *deviceState `json:"device,omitempty"` + Type string `json:"t"` + At string `json:"at"` + MonoMS int64 `json:"monoMS"` + IdleMS int64 `json:"idleMS,omitempty"` + WallIdleMS int64 `json:"wallIdleMS,omitempty"` + AfterWake bool `json:"afterWake,omitempty"` + SinceWakeMS int64 `json:"sinceWakeMS,omitempty"` + Direction string `json:"direction,omitempty"` + Size int `json:"size,omitempty"` + NetworkType string `json:"network,omitempty"` + NetworkPath string `json:"path,omitempty"` + By *Attribution `json:"by,omitempty"` + Device *deviceState `json:"device,omitempty"` + Window *wakeWindow `json:"window,omitempty"` + LogBaseMonoMS int64 `json:"logBaseMonoMS,omitempty"` +} + +type wakeWindow struct { + AwakeMS int64 `json:"awakeMS"` + CPUUserMS int64 `json:"cpuUserMS,omitempty"` + CPUSystemMS int64 `json:"cpuSystemMS,omitempty"` + EnergyNanojoules uint64 `json:"energyNJ,omitempty"` + PackageIdleWakeups uint64 `json:"packageIdleWakeups,omitempty"` + InterruptWakeups uint64 `json:"interruptWakeups,omitempty"` + DNSQueries uint64 `json:"dnsQueries,omitempty"` + ConnectionsOpened uint64 `json:"connectionsOpened,omitempty"` } type deviceState struct { diff --git a/service/powerreport/recorder.go b/service/powerreport/recorder.go index 920464aa..782fbcf1 100644 --- a/service/powerreport/recorder.go +++ b/service/powerreport/recorder.go @@ -42,6 +42,7 @@ const ( maxRowDNSDomains = 8 maxProfiles = 3 profileMinInterval = 2 * time.Minute + profileMaxDuration = 10 * time.Second ) type Options struct { @@ -70,10 +71,13 @@ type Recorder struct { fallbackInterval time.Duration baseTime time.Time - _ [64]byte - lastActivity atomic.Int64 - _ [64]byte + _ [64]byte + lastActivity atomic.Int64 + lastActivityWall atomic.Int64 + _ [64]byte + wakePending atomic.Bool + lastWakeNano atomic.Int64 lastSampleAt atomic.Int64 pendingBreak atomic.Pointer[breakRecord] notify chan struct{} @@ -102,17 +106,28 @@ type Recorder struct { deviceStateValid bool profiles []profileEntry lastProfileAt time.Time + profileFile *os.File + profileTimer *time.Timer + awake bool + wakeAt time.Time + wakeUsage systemUsage + wakeDNSQueries uint64 + wakeConnections uint64 + windowRate float64 done chan struct{} workerDone chan struct{} } type breakRecord struct { - at time.Time - idleMS int64 - direction Direction - size int - by *Attribution + at time.Time + idleMS int64 + wallIdleMS int64 + afterWake bool + sinceWakeMS int64 + direction Direction + size int + by *Attribution } type previousSample struct { @@ -212,6 +227,7 @@ func (r *Recorder) Start() error { r.lastSampleAt.Store(int64(now.Sub(r.baseTime))) r.lastFlushAt = now r.started = true + r.beginWakeWindowLocked(now) r.updateDeviceStateLocked(now) go r.worker() return nil @@ -229,6 +245,7 @@ func (r *Recorder) Close() error { <-r.workerDone now := time.Now() r.access.Lock() + r.stopProfileLocked() r.consumeBreakLocked() r.sampleLocked(now) r.flushLocked(now) @@ -260,26 +277,45 @@ func (r *Recorder) writeLog() { // and characterizes what ended an idle period; it is not accumulated. Volume totals come from // the sampled interface counters instead. func (r *Recorder) Touch(direction Direction, size int, by *Attribution) { - nowNano := int64(time.Since(r.baseTime)) + now := time.Now() + nowNano := int64(now.Sub(r.baseTime)) + if r.wakePending.Load() && r.wakePending.CompareAndSwap(true, false) { + record := r.newBreakRecord(now, nowNano, direction, size, by) + record.afterWake = true + record.sinceWakeMS = (nowNano - r.lastWakeNano.Load()) / int64(time.Millisecond) + r.pendingBreak.Store(record) + r.notifyWorker() + return + } lastNano := r.lastActivity.Load() if nowNano-lastNano < activityRefreshNano { return } - previousNano := r.lastActivity.Swap(nowNano) - if previousNano != 0 && nowNano-previousNano >= r.gateNano { - r.pendingBreak.Store(&breakRecord{ - at: time.Now(), - idleMS: (nowNano - previousNano) / int64(time.Millisecond), - direction: direction, - size: size, - by: by, - }) + record := r.newBreakRecord(now, nowNano, direction, size, by) + if record.idleMS >= r.gateNano/int64(time.Millisecond) { + r.pendingBreak.Store(record) r.notifyWorker() } else if nowNano-r.lastSampleAt.Load() >= r.sampleNano { r.notifyWorker() } } +func (r *Recorder) newBreakRecord(now time.Time, nowNano int64, direction Direction, size int, by *Attribution) *breakRecord { + previousNano := r.lastActivity.Swap(nowNano) + previousWall := r.lastActivityWall.Swap(now.UnixNano()) + record := &breakRecord{ + at: now, + direction: direction, + size: size, + by: by, + } + if previousNano != 0 { + record.idleMS = (nowNano - previousNano) / int64(time.Millisecond) + record.wallIdleMS = (now.UnixNano() - previousWall) / int64(time.Millisecond) + } + return record +} + func (r *Recorder) CountDNSQuery(domain string) { r.dnsQueries.Add(1) if domain == "" { @@ -296,13 +332,13 @@ func (r *Recorder) CountDNSQuery(domain string) { func (r *Recorder) RecordPauseEvent(event int) { switch event { case pause.EventDevicePaused: - r.RecordPlatformEvent(eventTypeDevicePause) + r.recordPlatformEvent(eventTypeDevicePause) case pause.EventDeviceWake: - r.RecordPlatformEvent(eventTypeDeviceWake) + r.recordPlatformEvent(eventTypeDeviceWake) case pause.EventNetworkPause: - r.RecordPlatformEvent(eventTypeNetworkPause) + r.recordPlatformEvent(eventTypeNetworkPause) case pause.EventNetworkWake: - r.RecordPlatformEvent(eventTypeNetworkWake) + r.recordPlatformEvent(eventTypeNetworkWake) } } @@ -310,17 +346,98 @@ func (r *Recorder) CountConnectionOpened() { r.connectionsOpened.Add(1) } -func (r *Recorder) RecordPlatformEvent(eventType string) { +// The service log prefixes each line with whole monotonic seconds since createdAt, so a +// line at [N] sits at logBaseMonoMS + N*1000 on the monoMS axis of events and timeline rows. +func (r *Recorder) RecordServiceStart(createdAt time.Time) { now := time.Now() r.access.Lock() if !r.started || r.closed { r.access.Unlock() return } - r.events = append(r.events, eventRecord{ - Type: eventType, - At: now.UTC().Format(time.RFC3339), - }) + event := r.newEvent(eventTypeService, now) + event.LogBaseMonoMS = int64(createdAt.Sub(r.baseTime) / time.Millisecond) + r.events = append(r.events, event) + r.access.Unlock() + r.notifyWorker() +} + +func (r *Recorder) RecordDeviceSleep() { + now := time.Now() + r.access.Lock() + if !r.started || r.closed { + r.access.Unlock() + return + } + r.wakePending.Store(false) + r.stopProfileLocked() + event := r.newEvent(eventTypeSleep, now) + if r.awake { + event.Window = r.endWakeWindowLocked(now) + } + r.events = append(r.events, event) + r.access.Unlock() + r.notifyWorker() +} + +func (r *Recorder) RecordDeviceWake() { + now := time.Now() + r.access.Lock() + if !r.started || r.closed { + r.access.Unlock() + return + } + r.events = append(r.events, r.newEvent(eventTypeWake, now)) + var request *profileRequest + if !r.awake { + r.beginWakeWindowLocked(now) + r.lastWakeNano.Store(int64(now.Sub(r.baseTime))) + r.wakePending.Store(true) + request = r.requestProfileLocked(now, r.windowRate) + r.windowRate = 0 + } + r.access.Unlock() + if request != nil { + r.startProfile(request) + } + r.notifyWorker() +} + +func (r *Recorder) beginWakeWindowLocked(now time.Time) { + r.awake = true + r.wakeAt = now + r.wakeUsage = readSystemUsage() + r.wakeDNSQueries = r.dnsQueries.Load() + r.wakeConnections = r.connectionsOpened.Load() +} + +func (r *Recorder) endWakeWindowLocked(now time.Time) *wakeWindow { + r.awake = false + usage := readSystemUsage() + window := &wakeWindow{ + AwakeMS: int64(now.Sub(r.wakeAt) / time.Millisecond), + DNSQueries: r.dnsQueries.Load() - r.wakeDNSQueries, + ConnectionsOpened: r.connectionsOpened.Load() - r.wakeConnections, + } + if usage.valid && r.wakeUsage.valid { + window.CPUUserMS = (usage.userTime - r.wakeUsage.userTime) / int64(time.Millisecond) + window.CPUSystemMS = (usage.systemTime - r.wakeUsage.systemTime) / int64(time.Millisecond) + window.EnergyNanojoules = usage.energyNanojoules - r.wakeUsage.energyNanojoules + window.PackageIdleWakeups = usage.packageIdleWakeups - r.wakeUsage.packageIdleWakeups + window.InterruptWakeups = usage.interruptWakeups - r.wakeUsage.interruptWakeups + } + r.windowRate = profileRate(window.EnergyNanojoules, window.CPUUserMS+window.CPUSystemMS, now.Sub(r.wakeAt)) + return window +} + +func (r *Recorder) recordPlatformEvent(eventType string) { + now := time.Now() + r.access.Lock() + if !r.started || r.closed { + r.access.Unlock() + return + } + r.events = append(r.events, r.newEvent(eventType, now)) r.access.Unlock() r.notifyWorker() } @@ -333,11 +450,9 @@ func (r *Recorder) UpdateNetworkType(networkType string) { return } r.networkType = networkType - r.events = append(r.events, eventRecord{ - Type: eventTypeNetwork, - At: now.UTC().Format(time.RFC3339), - NetworkType: networkType, - }) + event := r.newEvent(eventTypeNetwork, now) + event.NetworkType = networkType + r.events = append(r.events, event) r.access.Unlock() r.notifyWorker() } @@ -351,15 +466,21 @@ func (r *Recorder) UpdateNetworkPath(description string) { return } r.networkPath = description - r.events = append(r.events, eventRecord{ - Type: eventTypePath, - At: now.UTC().Format(time.RFC3339), - NetworkPath: description, - }) + event := r.newEvent(eventTypePath, now) + event.NetworkPath = description + r.events = append(r.events, event) r.access.Unlock() r.notifyWorker() } +func (r *Recorder) newEvent(eventType string, now time.Time) eventRecord { + return eventRecord{ + Type: eventType, + At: now.UTC().Format(time.RFC3339), + MonoMS: int64(now.Sub(r.baseTime) / time.Millisecond), + } +} + func (r *Recorder) notifyWorker() { select { case r.notify <- struct{}{}: @@ -402,7 +523,7 @@ func (r *Recorder) process() { } r.access.Unlock() if request != nil { - r.writeSampleProfile(request) + r.startProfile(request) } } @@ -411,15 +532,16 @@ func (r *Recorder) consumeBreakLocked() { if record == nil { return } - r.events = append(r.events, eventRecord{ - Type: eventTypeBreak, - At: record.at.UTC().Format(time.RFC3339), - IdleMS: record.idleMS, - Direction: record.direction.String(), - Size: record.size, - NetworkType: r.networkType, - By: record.by, - }) + event := r.newEvent(eventTypeBreak, record.at) + event.IdleMS = record.idleMS + event.WallIdleMS = record.wallIdleMS + event.AfterWake = record.afterWake + event.SinceWakeMS = record.sinceWakeMS + event.Direction = record.direction.String() + event.Size = record.size + event.NetworkType = r.networkType + event.By = record.by + r.events = append(r.events, event) } func (r *Recorder) resetPreviousLocked(now time.Time) { @@ -445,6 +567,8 @@ func (r *Recorder) sampleLocked(now time.Time) *profileRequest { row := timelineRow{ From: previous.at.UTC().Format(time.RFC3339), To: now.UTC().Format(time.RFC3339), + MonoMS: int64(now.Sub(r.baseTime) / time.Millisecond), + AwakeMS: int64(now.Sub(previous.at) / time.Millisecond), CPUGCMS: int64((current.gcSeconds - previous.gcSeconds) * 1000), Goroutines: r.metricsSamples[1].Value.Uint64(), GCCycles: current.gcCycles - previous.gcCycles, @@ -485,7 +609,7 @@ func (r *Recorder) sampleLocked(now time.Time) *profileRequest { } if current.absoluteTime != 0 && previous.absoluteTime != 0 && current.absoluteTime >= previous.absoluteTime { sleptNano := (current.continuousTime - previous.continuousTime) - (current.absoluteTime - previous.absoluteTime) - wallNano := now.Sub(previous.at).Nanoseconds() + wallNano := now.UnixNano() - previous.at.UnixNano() if sleptNano > wallNano { sleptNano = wallNano } @@ -519,7 +643,7 @@ func (r *Recorder) sampleLocked(now time.Time) *profileRequest { } r.updateDeviceStateLocked(now) r.rows = append(r.rows, row) - return r.requestProfileLocked(now, &row, now.Sub(previous.at)) + return r.requestProfileLocked(now, profileRate(row.EnergyNanojoules, row.CPUUserMS+row.CPUSystemMS, now.Sub(previous.at))) } func (r *Recorder) collectDNSDomains() map[string]uint64 { @@ -555,8 +679,9 @@ func trafficDeltas(current map[trafficKey]trafficBytes, previous map[trafficKey] for key, currentBytes := range current { previousBytes := previous[key] delta := trafficDelta{ - In: currentBytes.inBytes - previousBytes.inBytes, - Out: currentBytes.outBytes - previousBytes.outBytes, + In: currentBytes.inBytes - previousBytes.inBytes, + Out: currentBytes.outBytes - previousBytes.outBytes, + Dials: currentBytes.dials - previousBytes.dials, } if delta == (trafficDelta{}) { continue @@ -584,25 +709,26 @@ func (r *Recorder) updateDeviceStateLocked(now time.Time) { } r.lastDeviceState = state r.deviceStateValid = true - r.events = append(r.events, eventRecord{ - Type: eventTypeDevice, - At: now.UTC().Format(time.RFC3339), - Device: &state, - }) + event := r.newEvent(eventTypeDevice, now) + event.Device = &state + r.events = append(r.events, event) } -func (r *Recorder) requestProfileLocked(now time.Time, row *timelineRow, duration time.Duration) *profileRequest { - if r.closed || duration <= 0 { +func profileRate(energyNanojoules uint64, cpuMS int64, duration time.Duration) float64 { + if duration <= 0 { + return 0 + } + work := float64(energyNanojoules) + if work == 0 { + work = float64(cpuMS) + } + return work / duration.Seconds() +} + +func (r *Recorder) requestProfileLocked(now time.Time, rate float64) *profileRequest { + if r.closed || rate == 0 || r.profileFile != nil { return nil } - work := float64(row.EnergyNanojoules) - if work == 0 { - work = float64(row.CPUUserMS + row.CPUSystemMS) - } - if work == 0 { - return nil - } - rate := work / duration.Seconds() lowestIndex := -1 if len(r.profiles) >= maxProfiles { lowestIndex = 0 @@ -619,7 +745,7 @@ func (r *Recorder) requestProfileLocked(now time.Time, row *timelineRow, duratio return nil } request := &profileRequest{ - writePath: filepath.Join(r.draftPath, "goroutine-"+now.UTC().Format("150405")+".pb"), + writePath: filepath.Join(r.draftPath, "cpu-"+now.UTC().Format("150405")+".pb"), } if lowestIndex >= 0 { request.removePath = r.profiles[lowestIndex].path @@ -630,7 +756,7 @@ func (r *Recorder) requestProfileLocked(now time.Time, row *timelineRow, duratio return request } -func (r *Recorder) writeSampleProfile(request *profileRequest) { +func (r *Recorder) startProfile(request *profileRequest) { if request.removePath != "" { os.Remove(request.removePath) } @@ -638,9 +764,38 @@ func (r *Recorder) writeSampleProfile(request *profileRequest) { if err != nil { return } - defer file.Close() r.chown(request.writePath) - pprof.Lookup("goroutine").WriteTo(file, 0) + r.access.Lock() + defer r.access.Unlock() + if r.closed || r.profileFile != nil { + file.Close() + os.Remove(request.writePath) + return + } + err = pprof.StartCPUProfile(file) + if err != nil { + file.Close() + os.Remove(request.writePath) + return + } + r.profileFile = file + r.profileTimer = time.AfterFunc(profileMaxDuration, r.stopProfile) +} + +func (r *Recorder) stopProfile() { + r.access.Lock() + defer r.access.Unlock() + r.stopProfileLocked() +} + +func (r *Recorder) stopProfileLocked() { + if r.profileFile == nil { + return + } + r.profileTimer.Stop() + pprof.StopCPUProfile() + r.profileFile.Close() + r.profileFile = nil } func (r *Recorder) chown(path string) { diff --git a/service/powerreport/traffic.go b/service/powerreport/traffic.go index 41c0f3ea..5b530403 100644 --- a/service/powerreport/traffic.go +++ b/service/powerreport/traffic.go @@ -15,6 +15,14 @@ const maxTrafficCounters = 512 type TrafficCounter struct { inBytes atomic.Uint64 outBytes atomic.Uint64 + dials atomic.Uint64 +} + +func (c *TrafficCounter) CountDial() { + if c == nil { + return + } + c.dials.Add(1) } func (c *TrafficCounter) CountIn(n int64) { @@ -67,6 +75,7 @@ func (r *Recorder) snapshotTrafficLocked() map[trafficKey]trafficBytes { snapshot[key] = trafficBytes{ inBytes: counter.inBytes.Load(), outBytes: counter.outBytes.Load(), + dials: counter.dials.Load(), } } return snapshot diff --git a/transport/v2raygrpclite/client.go b/transport/v2raygrpclite/client.go index d8b3400e..62163e44 100644 --- a/transport/v2raygrpclite/client.go +++ b/transport/v2raygrpclite/client.go @@ -23,7 +23,7 @@ import ( "golang.org/x/net/http2" ) -var _ adapter.V2RayClientTransport = (*Client)(nil) +var _ adapter.V2RayMultiplexClientTransport = (*Client)(nil) var defaultClientHeader = http.Header{ "Content-Type": []string{"application/grpc"}, @@ -38,6 +38,7 @@ type Client struct { options option.V2RayGRPCOptions url *url.URL host string + closeIdle atomic.Bool } func NewClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, options option.V2RayGRPCOptions, tlsConfig tls.Config) adapter.V2RayClientTransport { @@ -92,6 +93,12 @@ func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { Host: c.host, } conn := newLateGunConn(pipeInWriter, cancel) + keepSession := adapter.KeepSessionFromContext(ctx) + conn.onClose = func() { + if c.closeIdle.Load() && !keepSession { + c.transport.CloseIdleConnections() + } + } handshakeTimeout := C.TCPTimeout if deadline, hasDeadline := ctx.Deadline(); hasDeadline { handshakeTimeout = time.Until(deadline) @@ -126,6 +133,17 @@ func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { return conn, nil } +func (c *Client) MultiplexEnabled() bool { + return true +} + +func (c *Client) SetKeepIdleConnections(keep bool) { + c.closeIdle.Store(!keep) + if !keep { + c.CloseIdleConnections() + } +} + func (c *Client) CloseIdleConnections() { c.transport.CloseIdleConnections() } diff --git a/transport/v2raygrpclite/conn.go b/transport/v2raygrpclite/conn.go index 896183b9..52911e62 100644 --- a/transport/v2raygrpclite/conn.go +++ b/transport/v2raygrpclite/conn.go @@ -30,6 +30,7 @@ type GunConn struct { err error cancel context.CancelFunc readRemaining int + onClose func() } func newGunConn(reader io.Reader, writer io.Writer, flusher http.Flusher) *GunConn { @@ -158,6 +159,9 @@ func (c *GunConn) Close() error { if c.cancel != nil { c.cancel() } + if c.onClose != nil { + c.onClose() + } return err } diff --git a/transport/v2rayhttp/client.go b/transport/v2rayhttp/client.go index 0b64c09f..9d21d91f 100644 --- a/transport/v2rayhttp/client.go +++ b/transport/v2rayhttp/client.go @@ -25,7 +25,7 @@ import ( "golang.org/x/net/http2" ) -var _ adapter.V2RayClientTransport = (*Client)(nil) +var _ adapter.V2RayMultiplexClientTransport = (*Client)(nil) type Client struct { ctx context.Context @@ -37,6 +37,7 @@ type Client struct { host []string method string headers http.Header + closeIdle atomic.Bool } func NewClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, options option.V2RayHTTPOptions, tlsConfig tls.Config) (adapter.V2RayClientTransport, error) { @@ -143,6 +144,12 @@ func (c *Client) dialHTTP2(ctx context.Context) (net.Conn, error) { request.Host = c.host[rand.Intn(hostLen)] } conn := NewLateHTTPConn(pipeInWriter, cancel) + keepSession := adapter.KeepSessionFromContext(ctx) + conn.onClose = func() { + if c.closeIdle.Load() && !keepSession { + CloseIdleConnections(c.transport.Load()) + } + } handshakeTimeout := C.TCPTimeout if deadline, hasDeadline := ctx.Deadline(); hasDeadline { handshakeTimeout = time.Until(deadline) @@ -170,8 +177,19 @@ func (c *Client) dialHTTP2(ctx context.Context) (net.Conn, error) { return conn, nil } +func (c *Client) MultiplexEnabled() bool { + return c.http2 +} + +func (c *Client) SetKeepIdleConnections(keep bool) { + c.closeIdle.Store(!keep) + if !keep { + c.CloseIdleConnections() + } +} + func (c *Client) CloseIdleConnections() { - CloseIdleConnections(c.transport) + CloseIdleConnections(c.transport.Load()) } func (c *Client) Close() error { diff --git a/transport/v2rayhttp/conn.go b/transport/v2rayhttp/conn.go index 758eda7f..80689aea 100644 --- a/transport/v2rayhttp/conn.go +++ b/transport/v2rayhttp/conn.go @@ -130,11 +130,12 @@ func (c *HTTPConn) Upstream() any { } type HTTP2Conn struct { - reader io.Reader - writer io.Writer - create chan struct{} - err error - cancel context.CancelFunc + reader io.Reader + writer io.Writer + create chan struct{} + err error + cancel context.CancelFunc + onClose func() } func NewHTTPConn(reader io.Reader, writer io.Writer) HTTP2Conn { @@ -189,6 +190,9 @@ func (c *HTTP2Conn) Close() error { if c.cancel != nil { c.cancel() } + if c.onClose != nil { + c.onClose() + } return err } diff --git a/transport/v2rayquic/client.go b/transport/v2rayquic/client.go index 105785d5..f780fb5e 100644 --- a/transport/v2rayquic/client.go +++ b/transport/v2rayquic/client.go @@ -6,6 +6,7 @@ import ( "context" "net" "sync" + "sync/atomic" "github.com/sagernet/quic-go" "github.com/sagernet/quic-go/http3" @@ -19,7 +20,7 @@ import ( N "github.com/sagernet/sing/common/network" ) -var _ adapter.V2RayClientTransport = (*Client)(nil) +var _ adapter.V2RayMultiplexClientTransport = (*Client)(nil) type Client struct { ctx context.Context @@ -30,14 +31,15 @@ type Client struct { connAccess sync.Mutex conn common.TypedValue[*clientConnection] rawConn net.Conn + closeIdle atomic.Bool } type clientConnection struct { *quic.Conn - access sync.Mutex - streams int - draining bool - closed bool + access sync.Mutex + streams int + closed bool + closeIdle *atomic.Bool } func (c *clientConnection) active() bool { @@ -54,14 +56,13 @@ func (c *clientConnection) acquireStream() bool { return false } c.streams++ - c.draining = false return true } -func (c *clientConnection) releaseStream() { +func (c *clientConnection) releaseStream(keepSession bool) { c.access.Lock() c.streams-- - closed := c.draining && c.streams == 0 && c.markClosedLocked() + closed := c.closeIdle.Load() && !keepSession && c.streams == 0 && c.markClosedLocked() c.access.Unlock() if closed { c.CloseWithError(0, "") @@ -126,7 +127,7 @@ func (c *Client) offerNew() (*clientConnection, error) { <-quicConn.Context().Done() udpConn.Close() }() - conn := &clientConnection{Conn: quicConn} + conn := &clientConnection{Conn: quicConn, closeIdle: &c.closeIdle} c.conn.Store(conn) c.rawConn = udpConn return conn, nil @@ -143,10 +144,22 @@ func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { } stream, err := conn.OpenStream() if err != nil { - conn.releaseStream() + conn.releaseStream(false) return nil, err } - return &StreamWrapper{Conn: conn.Conn, Stream: stream, onClose: conn.releaseStream}, nil + keepSession := adapter.KeepSessionFromContext(ctx) + return &StreamWrapper{Conn: conn.Conn, Stream: stream, onClose: func() { conn.releaseStream(keepSession) }}, nil + } +} + +func (c *Client) MultiplexEnabled() bool { + return true +} + +func (c *Client) SetKeepIdleConnections(keep bool) { + c.closeIdle.Store(!keep) + if !keep { + c.CloseIdleConnections() } } @@ -156,7 +169,6 @@ func (c *Client) CloseIdleConnections() { return } conn.access.Lock() - conn.draining = true closed := conn.streams == 0 && conn.markClosedLocked() conn.access.Unlock() if closed { From 21ba30b8c3c876ed4f2e9ee0ab477c25027a06ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 5 Sep 2026 13:11:37 +0800 Subject: [PATCH 46/66] Keep idle connections across short device sleeps --- experimental/libbox/command_server.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/experimental/libbox/command_server.go b/experimental/libbox/command_server.go index 45470b79..a5bd032e 100644 --- a/experimental/libbox/command_server.go +++ b/experimental/libbox/command_server.go @@ -40,8 +40,13 @@ type CommandServer struct { grpcServer *grpc.Server listener net.Listener endPauseTimer *time.Timer + sleepAt time.Time } +// iOS wakes the extension for every push and background task; in collected power reports +// most sleeps last under two minutes and none exceeded ten. +const closeIdleConnectionsAfterSleep = 2 * time.Minute + type CommandServerHandler interface { ServiceStop() error ServiceReload() error @@ -94,7 +99,7 @@ func NewCommandServer(handler CommandServerHandler, platformInterface PlatformIn OOMRecorder: oomRecorder, }) if sPowerReportEnabled { - err := powerManager.Start(PowerReportOptions(server.StartedService, platformInterface)) + err := powerManager.Start(PowerReportOptions(server.StartedService)) if err != nil { log.StdLogger().Error(E.Cause(err, "start power report recorder")) } @@ -256,6 +261,7 @@ func (s *CommandServer) NeedFindProcess() bool { } func (s *CommandServer) Pause() { + s.sleepAt = time.Now().Round(0) recorder := s.powerManager.Recorder() if recorder != nil { recorder.RecordDeviceSleep() @@ -264,7 +270,6 @@ func (s *CommandServer) Pause() { if instance == nil || instance.PauseManager() == nil { return } - instance.Box().CloseIdleConnections() instance.PauseManager().DevicePause() if C.IsIos { // iOS calls wake within seconds of sleep while the device stays locked, so wake is @@ -288,14 +293,18 @@ func (s *CommandServer) endDevicePause() { } func (s *CommandServer) Wake() { + wakeAt := time.Now().Round(0) recorder := s.powerManager.Recorder() if recorder != nil { recorder.RecordDeviceWake() } instance := s.StartedService.Instance() - if instance == nil || instance.PauseManager() == nil { + if instance == nil || instance.Box() == nil || instance.PauseManager() == nil { return } + if !s.sleepAt.IsZero() && wakeAt.Sub(s.sleepAt) >= closeIdleConnectionsAfterSleep { + instance.Box().CloseIdleConnections() + } if !C.IsIos { instance.PauseManager().DeviceWake() } From cdea9e87f1bc01a5ec9a56aab32199769ef5aa45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 5 Sep 2026 13:11:37 +0800 Subject: [PATCH 47/66] Log network path only when changed --- experimental/libbox/monitor.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/experimental/libbox/monitor.go b/experimental/libbox/monitor.go index 273b1275..1fe21d72 100644 --- a/experimental/libbox/monitor.go +++ b/experimental/libbox/monitor.go @@ -20,6 +20,7 @@ type platformDefaultInterfaceMonitor struct { callbacks list.List[tun.DefaultInterfaceUpdateCallback] myInterfaces []string defaultInterfaceInitialized bool + lastNetworkPath string } func (m *platformDefaultInterfaceMonitor) Start() error { @@ -57,7 +58,10 @@ func (m *platformDefaultInterfaceMonitor) UnregisterCallback(element *list.Eleme } func (m *platformDefaultInterfaceMonitor) UpdateNetworkPath(networkPath string) { - m.logger.Debug("updated network path: ", networkPath) + if networkPath != m.lastNetworkPath { + m.lastNetworkPath = networkPath + m.logger.Debug("updated network path: ", networkPath) + } if m.powerManager == nil { return } From 85c4a530b77631faad0d5cc91533077b52ed2438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 5 Sep 2026 13:11:37 +0800 Subject: [PATCH 48/66] Add platform metadata to reports --- experimental/boxdd/server.go | 2 +- experimental/libbox/log.go | 4 +++ experimental/libbox/power_report.go | 9 ++----- experimental/libbox/report.go | 42 ++++++++++++++++++++++------- experimental/libbox/setup.go | 12 ++++++++- 5 files changed, 51 insertions(+), 18 deletions(-) diff --git a/experimental/boxdd/server.go b/experimental/boxdd/server.go index cb195e01..ad9df8a2 100644 --- a/experimental/boxdd/server.go +++ b/experimental/boxdd/server.go @@ -223,7 +223,7 @@ func (d *Daemon) startServiceLocked(ctx context.Context, ownerUserID string, con }) d.startedService.SetOOMKillerOptions(options.OOMKillerEnabled, options.OOMKillerDisabled, uint64(options.OOMMemoryLimit)) if options.PowerReportEnabled { - err = d.powerManager.Start(libbox.PowerReportOptions(d.startedService, nil)) + err = d.powerManager.Start(libbox.PowerReportOptions(d.startedService)) if err != nil { d.logger.Warn("start power report recorder: ", err) } diff --git a/experimental/libbox/log.go b/experimental/libbox/log.go index 49a0e07a..992cf8f5 100644 --- a/experimental/libbox/log.go +++ b/experimental/libbox/log.go @@ -48,6 +48,10 @@ func archiveCrashReport(path string, crashReportsDir string) { reportMetadata: baseReportMetadata(), CrashedAt: crashTime.Format(time.RFC3339), } + platformSnapshot := readPlatformSnapshot() + if platformSnapshot != nil { + metadata.Platform = platformSnapshot + } writeReportMetadata(destPath, metadata) os.Remove(path) copyConfigSnapshot(destPath) diff --git a/experimental/libbox/power_report.go b/experimental/libbox/power_report.go index cad04136..2d90c62a 100644 --- a/experimental/libbox/power_report.go +++ b/experimental/libbox/power_report.go @@ -13,19 +13,14 @@ import ( type powerReportMetadata struct { reportMetadata - StartedAt string `json:"startedAt"` - IncludeAllNetworks *bool `json:"includeAllNetworks,omitempty"` + StartedAt string `json:"startedAt"` } -func PowerReportOptions(startedService *daemon.StartedService, platformInterface PlatformInterface) powerreport.Options { +func PowerReportOptions(startedService *daemon.StartedService) powerreport.Options { metadata := powerReportMetadata{ reportMetadata: baseReportMetadata(), StartedAt: time.Now().UTC().Format(time.RFC3339), } - if platformInterface != nil && platformInterface.UnderNetworkExtension() { - includeAllNetworks := platformInterface.IncludeAllNetworks() - metadata.IncludeAllNetworks = &includeAllNetworks - } return powerreport.Options{ BasePath: sWorkingPath, Logger: log.StdLogger(), diff --git a/experimental/libbox/report.go b/experimental/libbox/report.go index fc41f506..ff5e0f4f 100644 --- a/experimental/libbox/report.go +++ b/experimental/libbox/report.go @@ -16,15 +16,16 @@ import ( ) type reportMetadata struct { - Source string `json:"source,omitempty"` - BundleIdentifier string `json:"bundleIdentifier,omitempty"` - ProcessName string `json:"processName,omitempty"` - ProcessPath string `json:"processPath,omitempty"` - StartedAt string `json:"startedAt,omitempty"` - AppVersion string `json:"appVersion,omitempty"` - AppMarketingVersion string `json:"appMarketingVersion,omitempty"` - CoreVersion string `json:"coreVersion,omitempty"` - GoVersion string `json:"goVersion,omitempty"` + Source string `json:"source,omitempty"` + BundleIdentifier string `json:"bundleIdentifier,omitempty"` + ProcessName string `json:"processName,omitempty"` + ProcessPath string `json:"processPath,omitempty"` + StartedAt string `json:"startedAt,omitempty"` + AppVersion string `json:"appVersion,omitempty"` + AppMarketingVersion string `json:"appMarketingVersion,omitempty"` + CoreVersion string `json:"coreVersion,omitempty"` + GoVersion string `json:"goVersion,omitempty"` + Platform json.RawMessage `json:"platform,omitempty"` } func baseReportMetadata() reportMetadata { @@ -41,6 +42,7 @@ func baseReportMetadata() reportMetadata { AppMarketingVersion: sAppMarketingVersion, CoreVersion: C.Version, GoVersion: GoVersion(), + Platform: sPlatformMetadata, } } @@ -70,6 +72,28 @@ func copyConfigSnapshot(destPath string) { writeReportFile(destPath, "configuration.json", content) } +func platformSnapshotPath() string { + return filepath.Join(sWorkingPath, "PlatformMetadata-"+sCrashReportSource+".json") +} + +func savePlatformSnapshot() { + snapshotPath := platformSnapshotPath() + if sPlatformMetadata == nil { + os.Remove(snapshotPath) + return + } + os.WriteFile(snapshotPath, sPlatformMetadata, 0o666) + chownReport(snapshotPath) +} + +func readPlatformSnapshot() json.RawMessage { + content, err := os.ReadFile(platformSnapshotPath()) + if err != nil || !json.Valid(content) { + return nil + } + return content +} + func initReportDir(path string) { os.MkdirAll(path, 0o777) chownReport(path) diff --git a/experimental/libbox/setup.go b/experimental/libbox/setup.go index a78cba54..07f16734 100644 --- a/experimental/libbox/setup.go +++ b/experimental/libbox/setup.go @@ -1,6 +1,7 @@ package libbox import ( + "encoding/json" "math" "os" "path/filepath" @@ -37,6 +38,7 @@ var ( sOOMKillerDisabled bool sOOMMemoryLimit int64 sPowerReportEnabled bool + sPlatformMetadata []byte ) func init() { @@ -60,6 +62,7 @@ type SetupOptions struct { OomKillerDisabled bool OomMemoryLimit int64 PowerReportEnabled bool + PlatformMetadata string } func applySetupOptions(options *SetupOptions) { @@ -89,6 +92,11 @@ func ReloadSetupOptions(options *SetupOptions) { sOOMKillerDisabled = options.OomKillerDisabled sOOMMemoryLimit = options.OomMemoryLimit sPowerReportEnabled = options.PowerReportEnabled + if json.Valid([]byte(options.PlatformMetadata)) { + sPlatformMetadata = []byte(options.PlatformMetadata) + } else { + sPlatformMetadata = nil + } if sOOMKillerEnabled { if sOOMMemoryLimit == 0 && C.IsIos { sOOMMemoryLimit = oomkiller.DefaultAppleNetworkExtensionMemoryLimit @@ -108,7 +116,9 @@ func Setup(options *SetupOptions) error { applySetupOptions(options) os.MkdirAll(sWorkingPath, 0o777) os.MkdirAll(sTempPath, 0o777) - return redirectStderr(filepath.Join(sWorkingPath, "CrashReport-"+sCrashReportSource+".log")) + err := redirectStderr(filepath.Join(sWorkingPath, "CrashReport-"+sCrashReportSource+".log")) + savePlatformSnapshot() + return err } func SetLocale(localeID string) error { From 351ce12f7398a7995af5f6451e65039ff4902169 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 5 Sep 2026 16:53:14 +0800 Subject: [PATCH 49/66] Record reloads in reports and dump heap on reset --- daemon/instance.go | 8 +-- daemon/started_service.go | 12 ++++- service/oomkiller/recorder.go | 77 +++++++++++++++++++++++++++-- service/oomkiller/service.go | 10 ++-- service/oomkiller/service_darwin.go | 2 +- service/oomkiller/timer.go | 49 +++++++++++++----- service/powerreport/record.go | 1 + service/powerreport/recorder.go | 3 +- 8 files changed, 133 insertions(+), 29 deletions(-) diff --git a/daemon/instance.go b/daemon/instance.go index e60701dc..bb6a3403 100644 --- a/daemon/instance.go +++ b/daemon/instance.go @@ -82,7 +82,7 @@ type OverrideOptions struct { ExcludePackage []string } -func (s *StartedService) newInstance(ctx context.Context, profileContent string, overrideOptions *OverrideOptions) (*Instance, error) { +func (s *StartedService) newInstance(ctx context.Context, profileContent string, overrideOptions *OverrideOptions, reloading bool) (*Instance, error) { selectedLocale := locale.FromContext(ctx) ctx, _ = locale.ContextWithLocale(s.ctx, selectedLocale.Locale) ctx = service.ExtendContext(ctx) @@ -138,7 +138,7 @@ func (s *StartedService) newInstance(ctx context.Context, profileContent string, i.clashMode = service.PtrFromContext[clashmode.Manager](ctx) i.trafficManager = service.PtrFromContext[trafficcontrol.Manager](ctx) i.pauseManager = service.FromContext[pause.Manager](ctx) - i.registerPowerReport(ctx) + i.registerPowerReport(ctx, reloading) i.cacheFile = service.FromContext[adapter.CacheFile](ctx) i.outboundManager = service.FromContext[adapter.OutboundManager](ctx) i.endpointManager = service.FromContext[adapter.EndpointManager](ctx) @@ -176,14 +176,14 @@ func (i *Instance) Close() error { return i.instance.Close() } -func (i *Instance) registerPowerReport(ctx context.Context) { +func (i *Instance) registerPowerReport(ctx context.Context, reloading bool) { powerManager := service.FromContext[*powerreport.Manager](ctx) if powerManager == nil { return } recorder := powerManager.Recorder() if recorder != nil { - recorder.RecordServiceStart(i.instance.CreatedAt()) + recorder.RecordServiceStart(i.instance.CreatedAt(), reloading) } if i.pauseManager == nil { return diff --git a/daemon/started_service.go b/daemon/started_service.go index 5e8c7998..f1fb0f0d 100644 --- a/daemon/started_service.go +++ b/daemon/started_service.go @@ -20,6 +20,7 @@ import ( "github.com/sagernet/sing-box/experimental/locale" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/protocol/group" + "github.com/sagernet/sing-box/service/oomkiller" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/memory" "github.com/sagernet/sing/common/observable" @@ -259,15 +260,22 @@ func (s *StartedService) StartOrReloadService(ctx context.Context, profileConten s.instance = nil s.updateStatus(ServiceStatus_STOPPING) s.serviceAccess.Unlock() + oomRecorder := service.FromContext[*oomkiller.Recorder](s.ctx) + if oomRecorder != nil { + oomRecorder.BeginReload() + defer oomRecorder.EndReload() + } _ = oldInstance.Close() runtimeDebug.FreeOSMemory() s.serviceAccess.Lock() } s.startInterrupted = false s.updateStatus(ServiceStatus_STARTING) - s.resetLogs() + if oldInstance == nil { + s.resetLogs() + } s.serviceAccess.Unlock() - instance, err := s.newInstance(ctx, profileContent, options) + instance, err := s.newInstance(ctx, profileContent, options, oldInstance != nil) if err != nil { s.serviceAccess.Lock() s.updateStatusError(err) diff --git a/service/oomkiller/recorder.go b/service/oomkiller/recorder.go index 1ec6b4fa..23d0494f 100644 --- a/service/oomkiller/recorder.go +++ b/service/oomkiller/recorder.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "runtime" + runtimeDebug "runtime/debug" "runtime/metrics" "strconv" "sync" @@ -23,6 +24,7 @@ const ( eventsFileName = "events.jsonl" metadataFileName = "metadata.json" logFileName = "go.log" + heapDumpFileName = "heap.dump" lockFileName = ".lock" normalSampleInterval = time.Minute @@ -44,6 +46,7 @@ const ( eventTypeStart = "start" eventTypeStop = "stop" + eventTypeReload = "reload" eventTypePressure = "pressure" eventTypeState = "state" eventTypeReset = "reset" @@ -86,6 +89,8 @@ type Recorder struct { closed bool draftCreated bool draftLock *os.File + reloading bool + carriedState *timerState status ReportStatus lastRowAt time.Time lastRowState pressureState @@ -131,6 +136,7 @@ type eventRecord struct { Sequence int `json:"seq,omitempty"` Prefix string `json:"prefix,omitempty"` Runtime *runtimeStats `json:"runtime,omitempty"` + HeapDump bool `json:"heapDump,omitempty"` } type runtimeStats struct { @@ -217,7 +223,7 @@ func (r *Recorder) WriteReport() error { sample.availableKnown = true sample.available = memory.Available() } - err := r.snapshot(SnapshotReasonManual, sample, true) + err := r.snapshot(SnapshotReasonManual, sample, false, true) if err != nil { return E.Cause(err, "write snapshot") } @@ -243,12 +249,41 @@ func (r *Recorder) WriteReport() error { return nil } -func (r *Recorder) instanceStarted(config timerConfig, thresholds pressureThresholds) { +func (r *Recorder) BeginReload() { + r.access.Lock() + defer r.access.Unlock() + r.reloading = true +} + +func (r *Recorder) EndReload() { + r.access.Lock() + defer r.access.Unlock() + if !r.reloading { + return + } + r.reloading = false + if r.carriedState != nil { + r.carriedState = nil + r.appendEventLocked(eventRecord{Type: eventTypeStop}) + } +} + +func (r *Recorder) instanceStarted(config timerConfig, thresholds pressureThresholds) *timerState { r.access.Lock() defer r.access.Unlock() r.status.MemoryLimit = config.memoryLimit + eventType := eventTypeStart + var carriedState *timerState + if r.reloading { + r.reloading = false + carriedState = r.carriedState + r.carriedState = nil + if carriedState != nil { + eventType = eventTypeReload + } + } r.appendEventLocked(eventRecord{ - Type: eventTypeStart, + Type: eventType, Policy: config.policyMode.String(), MemoryLimit: config.memoryLimit, TriggerBytes: thresholds.trigger, @@ -256,11 +291,16 @@ func (r *Recorder) instanceStarted(config timerConfig, thresholds pressureThresh ResumeBytes: thresholds.resume, ReportOnly: config.killerDisabled, }) + return carriedState } -func (r *Recorder) instanceStopped() { +func (r *Recorder) instanceStopped(state timerState) { r.access.Lock() defer r.access.Unlock() + if r.reloading { + r.carriedState = &state + return + } r.appendEventLocked(eventRecord{Type: eventTypeStop}) } @@ -347,7 +387,7 @@ func (r *Recorder) recordReset(reason string, before memorySample, after memoryS }) } -func (r *Recorder) snapshot(reason string, sample memorySample, force bool) error { +func (r *Recorder) snapshot(reason string, sample memorySample, heapDump bool, force bool) error { r.snapshotAccess.Lock() defer r.snapshotAccess.Unlock() now := time.Now() @@ -387,6 +427,10 @@ func (r *Recorder) snapshot(reason string, sample memorySample, force bool) erro if r.snapshotCallback != nil { r.snapshotCallback(r.draftPath, prefix) } + var heapDumped bool + if heapDump { + heapDumped = r.writeHeapDump() + } var memStats runtime.MemStats runtime.ReadMemStats(&memStats) stats := &runtimeStats{ @@ -420,10 +464,33 @@ func (r *Recorder) snapshot(reason string, sample memorySample, force bool) erro MemoryBytes: sample.usage, AvailableBytes: sample.available, Runtime: stats, + HeapDump: heapDumped, }) return nil } +func (r *Recorder) writeHeapDump() bool { + dumpPath := filepath.Join(r.draftPath, heapDumpFileName) + tempPath := dumpPath + ".tmp" + file, err := os.Create(tempPath) + if err != nil { + r.logger.Error(E.Cause(err, "OOM report: create heap dump")) + return false + } + runtimeDebug.WriteHeapDump(file.Fd()) + err = file.Close() + if err == nil { + err = os.Rename(tempPath, dumpPath) + } + if err != nil { + os.Remove(tempPath) + r.logger.Error(E.Cause(err, "OOM report: write heap dump")) + return false + } + r.chown(dumpPath) + return true +} + func (r *Recorder) observeLocked(sample memorySample) { if sample.usage > r.status.PeakMemory { r.status.PeakMemory = sample.usage diff --git a/service/oomkiller/service.go b/service/oomkiller/service.go index 7ce51616..b46d7dfa 100644 --- a/service/oomkiller/service.go +++ b/service/oomkiller/service.go @@ -49,18 +49,20 @@ func (s *Service) startTimer() error { return E.New("memory pressure monitoring is not available on this platform without memory_limit") } s.adaptiveTimer = newAdaptiveTimer(s.logger, s.network, s.connections, s.recorder, s.timerConfig) + var carriedState *timerState if s.recorder != nil { - s.recorder.instanceStarted(s.timerConfig, s.adaptiveTimer.limitThresholds) + carriedState = s.recorder.instanceStarted(s.timerConfig, s.adaptiveTimer.limitThresholds) } - s.adaptiveTimer.start() + s.adaptiveTimer.start(carriedState) return nil } func (s *Service) stopTimer() { + var state timerState if s.adaptiveTimer != nil { - s.adaptiveTimer.stop() + state = s.adaptiveTimer.stop() } if s.recorder != nil { - s.recorder.instanceStopped() + s.recorder.instanceStopped(state) } } diff --git a/service/oomkiller/service_darwin.go b/service/oomkiller/service_darwin.go index a3814cde..a0a07486 100644 --- a/service/oomkiller/service_darwin.go +++ b/service/oomkiller/service_darwin.go @@ -100,7 +100,7 @@ func goMemoryPressureCallback(status C.ulong) { } s.adaptiveTimer.notifyPressure() if s.recorder != nil { - s.recorder.snapshot(SnapshotReasonPressure, sample, false) + s.recorder.snapshot(SnapshotReasonPressure, sample, false, false) } } } diff --git a/service/oomkiller/timer.go b/service/oomkiller/timer.go index 5dfdd7b3..a49bc4b9 100644 --- a/service/oomkiller/timer.go +++ b/service/oomkiller/timer.go @@ -98,6 +98,15 @@ func buildTimerConfig(options option.OOMKillerServiceOptions, memoryLimit uint64 }, nil } +type timerState struct { + state pressureState + currentInterval time.Duration + forceMinInterval bool + pendingPressureBaseline bool + pressureBaseline memorySample + pressureBaselineTime time.Time +} + type adaptiveTimer struct { timerConfig logger log.ContextLogger @@ -106,14 +115,9 @@ type adaptiveTimer struct { recorder *Recorder limitThresholds pressureThresholds - access sync.Mutex - timer *time.Timer - state pressureState - currentInterval time.Duration - forceMinInterval bool - pendingPressureBaseline bool - pressureBaseline memorySample - pressureBaselineTime time.Time + access sync.Mutex + timer *time.Timer + timerState } func newAdaptiveTimer(logger log.ContextLogger, network adapter.NetworkManager, connections adapter.ConnectionManager, recorder *Recorder, config timerConfig) *adaptiveTimer { @@ -130,9 +134,17 @@ func newAdaptiveTimer(logger log.ContextLogger, network adapter.NetworkManager, return t } -func (t *adaptiveTimer) start() { +func (t *adaptiveTimer) start(carriedState *timerState) { t.access.Lock() defer t.access.Unlock() + if t.timer != nil { + return + } + if carriedState != nil { + t.timerState = *carriedState + t.timer = time.AfterFunc(t.minInterval, t.poll) + return + } t.startLocked() } @@ -145,13 +157,14 @@ func (t *adaptiveTimer) startLocked() { t.timer = time.AfterFunc(t.minInterval, t.poll) } -func (t *adaptiveTimer) stop() { +func (t *adaptiveTimer) stop() timerState { t.access.Lock() defer t.access.Unlock() if t.timer != nil { t.timer.Stop() t.timer = nil } + return t.timerState } func (t *adaptiveTimer) poll() { @@ -230,8 +243,20 @@ func (t *adaptiveTimer) poll() { badCleanup() runtimeDebug.FreeOSMemory() if t.recorder != nil { - t.recorder.recordReset(reason, sample, readMemorySample(t.policyMode), connections, t.killerDisabled) - t.recorder.snapshot(SnapshotReasonReset, sample, false) + after := readMemorySample(t.policyMode) + t.recorder.recordReset(reason, sample, after, connections, t.killerDisabled) + t.recorder.snapshot(SnapshotReasonReset, sample, t.belowTrigger(after), false) + } +} + +func (t *adaptiveTimer) belowTrigger(sample memorySample) bool { + switch t.policyMode { + case policyModeMemoryLimit, policyModeNetworkExtension: + return sample.usage < t.limitThresholds.trigger + case policyModeAvailable: + return !sample.availableKnown || sample.available > t.availableThresholds(sample).trigger + default: + return true } } diff --git a/service/powerreport/record.go b/service/powerreport/record.go index d8e555ef..4dd5f939 100644 --- a/service/powerreport/record.go +++ b/service/powerreport/record.go @@ -132,6 +132,7 @@ type eventRecord struct { Device *deviceState `json:"device,omitempty"` Window *wakeWindow `json:"window,omitempty"` LogBaseMonoMS int64 `json:"logBaseMonoMS,omitempty"` + Reload bool `json:"reload,omitempty"` } type wakeWindow struct { diff --git a/service/powerreport/recorder.go b/service/powerreport/recorder.go index 782fbcf1..06bbdaac 100644 --- a/service/powerreport/recorder.go +++ b/service/powerreport/recorder.go @@ -348,7 +348,7 @@ func (r *Recorder) CountConnectionOpened() { // The service log prefixes each line with whole monotonic seconds since createdAt, so a // line at [N] sits at logBaseMonoMS + N*1000 on the monoMS axis of events and timeline rows. -func (r *Recorder) RecordServiceStart(createdAt time.Time) { +func (r *Recorder) RecordServiceStart(createdAt time.Time, reload bool) { now := time.Now() r.access.Lock() if !r.started || r.closed { @@ -357,6 +357,7 @@ func (r *Recorder) RecordServiceStart(createdAt time.Time) { } event := r.newEvent(eventTypeService, now) event.LogBaseMonoMS = int64(createdAt.Sub(r.baseTime) / time.Millisecond) + event.Reload = reload r.events = append(r.events, event) r.access.Unlock() r.notifyWorker() From 7a3d4e4a8e71bd7fa824959efdb57b4f39738802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 6 Sep 2026 06:53:12 +0800 Subject: [PATCH 50/66] Add goroutine dump and hang trigger to libbox --- experimental/libbox/debug.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/experimental/libbox/debug.go b/experimental/libbox/debug.go index 75942976..87de1249 100644 --- a/experimental/libbox/debug.go +++ b/experimental/libbox/debug.go @@ -1,6 +1,7 @@ package libbox import ( + "runtime" "time" "unsafe" ) @@ -10,3 +11,18 @@ func TriggerGoPanic() { *(*int)(unsafe.Pointer(uintptr(0))) = 0 }) } + +func TriggerGoHang(seconds int32) { + time.Sleep(time.Duration(seconds) * time.Second) +} + +func GoroutineDump() string { + buffer := make([]byte, 64*1024) + for { + n := runtime.Stack(buffer, true) + if n < len(buffer) { + return string(buffer[:n]) + } + buffer = make([]byte, 2*len(buffer)) + } +} From 6e3c86f518166f81fa330f6195b6a803bb1110e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 8 Sep 2026 18:26:29 +0800 Subject: [PATCH 51/66] Use screen state to end device pause on iOS --- constant/os_not_tvos.go | 5 +++ constant/os_tvos.go | 5 +++ experimental/libbox/command_server.go | 61 +++++++++++---------------- service/powerreport/record.go | 2 + service/powerreport/recorder.go | 8 ++++ 5 files changed, 45 insertions(+), 36 deletions(-) create mode 100644 constant/os_not_tvos.go create mode 100644 constant/os_tvos.go diff --git a/constant/os_not_tvos.go b/constant/os_not_tvos.go new file mode 100644 index 00000000..7a41ac35 --- /dev/null +++ b/constant/os_not_tvos.go @@ -0,0 +1,5 @@ +//go:build !(ios && tvos) + +package constant + +const IsTvOS = false diff --git a/constant/os_tvos.go b/constant/os_tvos.go new file mode 100644 index 00000000..bacacf44 --- /dev/null +++ b/constant/os_tvos.go @@ -0,0 +1,5 @@ +//go:build ios && tvos + +package constant + +const IsTvOS = true diff --git a/experimental/libbox/command_server.go b/experimental/libbox/command_server.go index a5bd032e..5dd456da 100644 --- a/experimental/libbox/command_server.go +++ b/experimental/libbox/command_server.go @@ -39,14 +39,8 @@ type CommandServer struct { oomRecorder *oomkiller.Recorder grpcServer *grpc.Server listener net.Listener - endPauseTimer *time.Timer - sleepAt time.Time } -// iOS wakes the extension for every push and background task; in collected power reports -// most sleeps last under two minutes and none exceeded ten. -const closeIdleConnectionsAfterSleep = 2 * time.Minute - type CommandServerHandler interface { ServiceStop() error ServiceReload() error @@ -198,9 +192,6 @@ func (s *CommandServer) Start() error { } func (s *CommandServer) Close() { - if s.endPauseTimer != nil { - s.endPauseTimer.Stop() - } if s.grpcServer != nil { s.grpcServer.Stop() } @@ -260,31 +251,32 @@ func (s *CommandServer) NeedFindProcess() bool { return instance.Box().Router().NeedFindProcess() } +// iOS wakes the extension for every push and background task, so wake is ignored there and +// the pause ends on the screen state instead. func (s *CommandServer) Pause() { - s.sleepAt = time.Now().Round(0) recorder := s.powerManager.Recorder() if recorder != nil { recorder.RecordDeviceSleep() } - instance := s.StartedService.Instance() - if instance == nil || instance.PauseManager() == nil { + if !(C.IsAndroid || C.IsIos) || C.IsTvOS { return } - instance.PauseManager().DevicePause() - if C.IsIos { - // iOS calls wake within seconds of sleep while the device stays locked, so wake is - // ignored and the pause ends one minute after the last sleep instead. Go timers on - // darwin run on CLOCK_UPTIME_RAW, which does not advance while the device sleeps, - // so the minute counts awake time only and never expires inside a sleep. - if s.endPauseTimer == nil { - s.endPauseTimer = time.AfterFunc(time.Minute, s.endDevicePause) - } else { - s.endPauseTimer.Reset(time.Minute) - } + instance := s.StartedService.Instance() + if instance == nil || instance.Box() == nil || instance.PauseManager() == nil { + return } + instance.Box().CloseIdleConnections() + instance.PauseManager().DevicePause() } -func (s *CommandServer) endDevicePause() { +func (s *CommandServer) Wake() { + recorder := s.powerManager.Recorder() + if recorder != nil { + recorder.RecordDeviceWake() + } + if !C.IsAndroid { + return + } instance := s.StartedService.Instance() if instance == nil || instance.PauseManager() == nil { return @@ -292,21 +284,18 @@ func (s *CommandServer) endDevicePause() { instance.PauseManager().DeviceWake() } -func (s *CommandServer) Wake() { - wakeAt := time.Now().Round(0) - recorder := s.powerManager.Recorder() - if recorder != nil { - recorder.RecordDeviceWake() - } +func (s *CommandServer) WakeNow() { instance := s.StartedService.Instance() - if instance == nil || instance.Box() == nil || instance.PauseManager() == nil { + if instance == nil || instance.PauseManager() == nil { return } - if !s.sleepAt.IsZero() && wakeAt.Sub(s.sleepAt) >= closeIdleConnectionsAfterSleep { - instance.Box().CloseIdleConnections() - } - if !C.IsIos { - instance.PauseManager().DeviceWake() + instance.PauseManager().DeviceWake() +} + +func (s *CommandServer) RecordScreenState(on bool) { + recorder := s.powerManager.Recorder() + if recorder != nil { + recorder.RecordScreenState(on) } } diff --git a/service/powerreport/record.go b/service/powerreport/record.go index 4dd5f939..06c4e085 100644 --- a/service/powerreport/record.go +++ b/service/powerreport/record.go @@ -113,6 +113,8 @@ const ( eventTypeNetworkWake = "network-wake" eventTypeSleep = "ne-sleep" eventTypeWake = "ne-wake" + eventTypeScreenOn = "screen-on" + eventTypeScreenOff = "screen-off" eventTypeService = "service" ) diff --git a/service/powerreport/recorder.go b/service/powerreport/recorder.go index 06bbdaac..abe8be95 100644 --- a/service/powerreport/recorder.go +++ b/service/powerreport/recorder.go @@ -342,6 +342,14 @@ func (r *Recorder) RecordPauseEvent(event int) { } } +func (r *Recorder) RecordScreenState(on bool) { + if on { + r.recordPlatformEvent(eventTypeScreenOn) + } else { + r.recordPlatformEvent(eventTypeScreenOff) + } +} + func (r *Recorder) CountConnectionOpened() { r.connectionsOpened.Add(1) } From f95aa6ac11d0dad8d83529ddbd25e578ed5733eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 28 Sep 2026 09:59:56 +0800 Subject: [PATCH 52/66] Update dependencies --- go.mod | 12 ++++++------ go.sum | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/go.mod b/go.mod index 05bd89dc..3ba2ebe2 100644 --- a/go.mod +++ b/go.mod @@ -45,17 +45,17 @@ require ( github.com/sagernet/netlink v0.0.0-20260814022025-64455d367bbf github.com/sagernet/nftables v0.3.0-mod.4 github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 - github.com/sagernet/sing v0.9.6-0.20260927091426-5f9aad7def20 - github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 - github.com/sagernet/sing-mux v0.3.9-0.20260927144857-0257b84c582a - github.com/sagernet/sing-openconnect v0.0.0-20260810065514-53aa8058f8df - github.com/sagernet/sing-openvpn v0.0.0-20260729104525-103eb5fe5eb6 + github.com/sagernet/sing v0.9.6 + github.com/sagernet/sing-cloudflared v0.1.3 + github.com/sagernet/sing-mux v0.3.9 + github.com/sagernet/sing-openconnect v0.1.0 + github.com/sagernet/sing-openvpn v0.1.0 github.com/sagernet/sing-quic v0.7.2-0.20261002084117-75c3ac4fa12b github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1 github.com/sagernet/sing-snell v0.0.0-20260904135315-bc5a12ac736f - github.com/sagernet/sing-tun v0.9.6-0.20260924001923-ddaa4ca25e3b + github.com/sagernet/sing-tun v0.9.6 github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca github.com/sagernet/sing-vmess v0.2.8 github.com/sagernet/smux v1.5.50-sing-box-mod.1 diff --git a/go.sum b/go.sum index 0dfadd2d..8fbbc41e 100644 --- a/go.sum +++ b/go.sum @@ -318,16 +318,16 @@ github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4IC github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 h1:sW2O+DoNF+my1PGlSZ3zG2N+fdxLR3DC0l4sXCM0lBE= github.com/sagernet/quic-go v0.61.0-sing-box-mod.7/go.mod h1:hmLC8GJPp+BrpBgrgJnacvI4fZGIhasuy1ROJJ8GR4E= -github.com/sagernet/sing v0.9.6-0.20260927091426-5f9aad7def20 h1:FRiFCD9vYplVTPrHJBlGmq1MhL9T3Eylee2b7yV7+l0= -github.com/sagernet/sing v0.9.6-0.20260927091426-5f9aad7def20/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= -github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6++yIa8XlDhxPkpR4p+7RUHVY2KTP9CPIGnWmOlO8= -github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= -github.com/sagernet/sing-mux v0.3.9-0.20260927144857-0257b84c582a h1:VrlU4QE8Bh4kEeLgvvWLBtyTiuXqc6/5vf3B+xQCTRE= -github.com/sagernet/sing-mux v0.3.9-0.20260927144857-0257b84c582a/go.mod h1:jricpwCOndDsTEM1TM3piJvhL/2Mm7OhLkm+JadJJ1Q= -github.com/sagernet/sing-openconnect v0.0.0-20260810065514-53aa8058f8df h1:ogyC7KPTWueN7kXFhKSdHeS5/t/zBjQgAOpWjqnrMos= -github.com/sagernet/sing-openconnect v0.0.0-20260810065514-53aa8058f8df/go.mod h1:4AKZLVcvY3r54UaK2Gbnm7aN8pOwdLz+y4EP0QFZ5Eg= -github.com/sagernet/sing-openvpn v0.0.0-20260729104525-103eb5fe5eb6 h1:ZAvhkor0prJ8KEX9EmTEH+gJ1WlA3ffjS8GZT6KKq9c= -github.com/sagernet/sing-openvpn v0.0.0-20260729104525-103eb5fe5eb6/go.mod h1:PWX7WygD8jpwfqfaGNySXpJYTn0SOwjBI1BKHHC2+Bw= +github.com/sagernet/sing v0.9.6 h1:LXTpEMbr4G/65PSjopjfg+Zp1Xq1ilJnGWq47y/eObI= +github.com/sagernet/sing v0.9.6/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= +github.com/sagernet/sing-cloudflared v0.1.3 h1:J5v401QFvUzzamw0udgs1kOPgnmPyPjo9jbPUiJgBXo= +github.com/sagernet/sing-cloudflared v0.1.3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= +github.com/sagernet/sing-mux v0.3.9 h1:fhk1tA1OHkDXHedXLanyu/mUepykCXENM9s6sEymo84= +github.com/sagernet/sing-mux v0.3.9/go.mod h1:jricpwCOndDsTEM1TM3piJvhL/2Mm7OhLkm+JadJJ1Q= +github.com/sagernet/sing-openconnect v0.1.0 h1:nen3Hhr52CHASJtduUwZfMhqgLD30A1LE+SN6Ju2X0Y= +github.com/sagernet/sing-openconnect v0.1.0/go.mod h1:4AKZLVcvY3r54UaK2Gbnm7aN8pOwdLz+y4EP0QFZ5Eg= +github.com/sagernet/sing-openvpn v0.1.0 h1:bmULMzzDP0EsO0UMkbiiNLIvEIKBlozM+ZD3GiJ2Vtg= +github.com/sagernet/sing-openvpn v0.1.0/go.mod h1:PWX7WygD8jpwfqfaGNySXpJYTn0SOwjBI1BKHHC2+Bw= github.com/sagernet/sing-quic v0.7.2-0.20261002084117-75c3ac4fa12b h1:2Jx/URJAPt32MEbLmAR5nzH87tO6NQZLRfRFvFqBWuk= github.com/sagernet/sing-quic v0.7.2-0.20261002084117-75c3ac4fa12b/go.mod h1:rJFeVxbEej2iuyeCJ3drKW+p5rOgtLIW3XhZ6ty7Fig= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= @@ -338,8 +338,8 @@ github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkG github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= github.com/sagernet/sing-snell v0.0.0-20260904135315-bc5a12ac736f h1:tbTCI+iis5sTmua7a6Q7vebcSRURok/LGgnEafnQAsc= github.com/sagernet/sing-snell v0.0.0-20260904135315-bc5a12ac736f/go.mod h1:et8Lws4f5QbOrY65DmjevHGup3mijJkhswkto6cwciM= -github.com/sagernet/sing-tun v0.9.6-0.20260924001923-ddaa4ca25e3b h1:tjQUA4fQi0OWDMMYfRigzvNlBTYyP/01e+P9BT/RZ4g= -github.com/sagernet/sing-tun v0.9.6-0.20260924001923-ddaa4ca25e3b/go.mod h1:3EgPst7agntRO7D6GOsiZ1l9FoqdLeuWmKT5TnWkmf0= +github.com/sagernet/sing-tun v0.9.6 h1:6NfiiT7FZMTnqs+Vvn/APQPMvdOGJ00DM4GPCe9mw1U= +github.com/sagernet/sing-tun v0.9.6/go.mod h1:3EgPst7agntRO7D6GOsiZ1l9FoqdLeuWmKT5TnWkmf0= github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca h1:5wA+IE0Fq1CGVLOgSpm0gKKZ03HzugcJ2JyhmDqeX6A= github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca/go.mod h1:ADAZZU85MnM91XNhd2TdZRTaIbA7RjTZuQ1KCHcUbNg= github.com/sagernet/sing-vmess v0.2.8 h1:xd5nnDOMlC76RgrLksS4jlk3eMt3c3CvQY3NsjWPWeI= From 79fc394d671c3b9077d108c28fd39156b13fc6b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 28 Sep 2026 12:17:04 +0800 Subject: [PATCH 53/66] documentation: Update Apple App Store link --- docs/clients/apple/index.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/clients/apple/index.md b/docs/clients/apple/index.md index 5693d491..892eb4e6 100644 --- a/docs/clients/apple/index.md +++ b/docs/clients/apple/index.md @@ -7,10 +7,6 @@ icon: material/apple SFI/SFM/SFT allows users to manage and run local or remote sing-box configuration files, and provides platform-specific function implementation, such as TUN transparent proxy implementation. -!!! failure "" - - We are temporarily unable to update sing-box apps on the App Store because the reviewer mistakenly found that we violated the rules (TestFlight users are not affected). - ## :material-graph: Requirements * iOS 15.0+ / macOS 13.0+ / Apple tvOS 17.0+ @@ -18,7 +14,7 @@ platform-specific function implementation, such as TUN transparent proxy impleme ## :material-download: Download -* ~~[App Store](https://apps.apple.com/app/sing-box-vt/id6673731168)~~ +* [App Store](https://apps.apple.com/app/sing-box-mt/id6785326793) (iOS / Apple tvOS) * TestFlight (Beta) TestFlight quota is only available to [sponsors](https://github.com/sponsors/nekohasekai) From a0d775ea6610fe1f7da842762cddb6376792cc81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 28 Sep 2026 16:48:13 +0800 Subject: [PATCH 54/66] Fix zero UDP checksum --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 3ba2ebe2..c9868858 100644 --- a/go.mod +++ b/go.mod @@ -55,7 +55,7 @@ require ( github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1 github.com/sagernet/sing-snell v0.0.0-20260904135315-bc5a12ac736f - github.com/sagernet/sing-tun v0.9.6 + github.com/sagernet/sing-tun v0.9.7-0.20260928080829-8ab0e83967b4 github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca github.com/sagernet/sing-vmess v0.2.8 github.com/sagernet/smux v1.5.50-sing-box-mod.1 diff --git a/go.sum b/go.sum index 8fbbc41e..02e9953b 100644 --- a/go.sum +++ b/go.sum @@ -338,8 +338,8 @@ github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkG github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= github.com/sagernet/sing-snell v0.0.0-20260904135315-bc5a12ac736f h1:tbTCI+iis5sTmua7a6Q7vebcSRURok/LGgnEafnQAsc= github.com/sagernet/sing-snell v0.0.0-20260904135315-bc5a12ac736f/go.mod h1:et8Lws4f5QbOrY65DmjevHGup3mijJkhswkto6cwciM= -github.com/sagernet/sing-tun v0.9.6 h1:6NfiiT7FZMTnqs+Vvn/APQPMvdOGJ00DM4GPCe9mw1U= -github.com/sagernet/sing-tun v0.9.6/go.mod h1:3EgPst7agntRO7D6GOsiZ1l9FoqdLeuWmKT5TnWkmf0= +github.com/sagernet/sing-tun v0.9.7-0.20260928080829-8ab0e83967b4 h1:8LMh5gE8agrhOqeaTg9xML99oVeP8ApxqC0uKeS+ruk= +github.com/sagernet/sing-tun v0.9.7-0.20260928080829-8ab0e83967b4/go.mod h1:3EgPst7agntRO7D6GOsiZ1l9FoqdLeuWmKT5TnWkmf0= github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca h1:5wA+IE0Fq1CGVLOgSpm0gKKZ03HzugcJ2JyhmDqeX6A= github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca/go.mod h1:ADAZZU85MnM91XNhd2TdZRTaIbA7RjTZuQ1KCHcUbNg= github.com/sagernet/sing-vmess v0.2.8 h1:xd5nnDOMlC76RgrLksS4jlk3eMt3c3CvQY3NsjWPWeI= From 52f9bad034d291fc16869a396bf812508d50ecd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 28 Sep 2026 17:28:56 +0800 Subject: [PATCH 55/66] Fix protocol input validation --- common/ja3/parser.go | 18 ++++++--- common/sniff/quic.go | 5 ++- dns/transport/fakeip/fakeip.go | 6 ++- go.mod | 20 +++++----- go.sum | 40 ++++++++++---------- protocol/bridge/packet.go | 7 ++++ protocol/naive/inbound.go | 15 +++++++- protocol/shadowsocks/inbound_multi.go | 31 ++++++++++----- protocol/tailscale/dns_transport.go | 10 ++++- protocol/tailscale/taildrop.go | 2 +- protocol/tailscale/taildrop_send.go | 4 +- protocol/tailscale/tailssh/server.go | 3 ++ route/route.go | 1 - transport/openconnect/device_system_stack.go | 6 +++ transport/openvpn/device_system_stack.go | 6 +++ transport/trojan/mux.go | 3 +- transport/v2raygrpclite/conn.go | 15 ++++---- transport/v2rayhttp/server.go | 8 ++++ transport/v2rayhttpupgrade/client.go | 3 +- transport/v2rayhttpupgrade/server.go | 15 +++++++- transport/v2raywebsocket/client.go | 6 ++- transport/v2raywebsocket/conn.go | 12 +++--- 22 files changed, 165 insertions(+), 71 deletions(-) diff --git a/common/ja3/parser.go b/common/ja3/parser.go index a4bd7123..421e51c2 100644 --- a/common/ja3/parser.go +++ b/common/ja3/parser.go @@ -264,16 +264,24 @@ func (j *ClientHello) parseExtensions(exs []byte) error { return &ParseError{LengthErr, 19} } versionsLen := int(sex[0]) - for i := 0; i < versionsLen; i += 2 { - versions = append(versions, binary.BigEndian.Uint16(sex[1:][i:])) + sex = sex[versionExtensionHeaderLen:] + if len(sex) < versionsLen { + return &ParseError{LengthErr, 21} + } + for i := range versionsLen / 2 { + versions = append(versions, binary.BigEndian.Uint16(sex[i*2:])) } case signatureAlgorithmsExtensionType: if len(sex) < signatureAlgorithmsExtensionHeaderLen { return &ParseError{LengthErr, 20} } - ssaLen := binary.BigEndian.Uint16(sex) - for i := 0; i < int(ssaLen); i += 2 { - signatureAlgorithms = append(signatureAlgorithms, binary.BigEndian.Uint16(sex[2:][i:])) + ssaLen := int(binary.BigEndian.Uint16(sex)) + sex = sex[signatureAlgorithmsExtensionHeaderLen:] + if len(sex) < ssaLen { + return &ParseError{LengthErr, 22} + } + for i := range ssaLen / 2 { + signatureAlgorithms = append(signatureAlgorithms, binary.BigEndian.Uint16(sex[i*2:])) } } exs = exs[4+exLen:] diff --git a/common/sniff/quic.go b/common/sniff/quic.go index 049bd2c1..4f6dcf06 100644 --- a/common/sniff/quic.go +++ b/common/sniff/quic.go @@ -245,6 +245,9 @@ func QUICClientHello(ctx context.Context, metadata *adapter.InboundContext, pack if err != nil { return err } + if length > uint64(decryptedReader.Len()) { + return os.ErrInvalid + } index := len(decrypted) - decryptedReader.Len() fragments = append(fragments, qCryptoFragment{offset, length, decrypted[index : index+int(length)]}) _, err = decryptedReader.Seek(int64(length), io.SeekCurrent) @@ -291,7 +294,7 @@ func QUICClientHello(ctx context.Context, metadata *adapter.InboundContext, pack find: for { for _, fragment := range fragments { - if fragment.offset == index { + if fragment.offset == index && fragment.length > 0 { buffer.Write(fragment.payload) index = fragment.offset + fragment.length length++ diff --git a/dns/transport/fakeip/fakeip.go b/dns/transport/fakeip/fakeip.go index 75db1cf8..025ddecf 100644 --- a/dns/transport/fakeip/fakeip.go +++ b/dns/transport/fakeip/fakeip.go @@ -67,7 +67,11 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, if question.Qtype == mDNS.TypeA && !t.inet4Enabled || question.Qtype == mDNS.TypeAAAA && !t.inet6Enabled { return dns.FixedResponseStatus(message, mDNS.RcodeSuccess), nil } - address, err := t.store.Create(dns.FqdnToDomain(question.Name), question.Qtype == mDNS.TypeAAAA) + domain := dns.FqdnToDomain(question.Name) + if len(domain) > 255 { + return nil, E.New("domain name too long for fakeip") + } + address, err := t.store.Create(domain, question.Qtype == mDNS.TypeAAAA) if err != nil { return nil, err } diff --git a/go.mod b/go.mod index c9868858..56567a99 100644 --- a/go.mod +++ b/go.mod @@ -45,19 +45,19 @@ require ( github.com/sagernet/netlink v0.0.0-20260814022025-64455d367bbf github.com/sagernet/nftables v0.3.0-mod.4 github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 - github.com/sagernet/sing v0.9.6 - github.com/sagernet/sing-cloudflared v0.1.3 - github.com/sagernet/sing-mux v0.3.9 - github.com/sagernet/sing-openconnect v0.1.0 - github.com/sagernet/sing-openvpn v0.1.0 + github.com/sagernet/sing v0.9.7-0.20260929145605-8d939df03478 + github.com/sagernet/sing-cloudflared v0.1.4-0.20260929150702-b3a1e8f3018c + github.com/sagernet/sing-mux v0.3.10-0.20260929204512-caf09fe32475 + github.com/sagernet/sing-openconnect v0.1.1-0.20260929151225-cbd68c45de58 + github.com/sagernet/sing-openvpn v0.1.1-0.20260929151220-7c3045dbddce github.com/sagernet/sing-quic v0.7.2-0.20261002084117-75c3ac4fa12b - github.com/sagernet/sing-shadowsocks v0.2.8 - github.com/sagernet/sing-shadowsocks2 v0.2.1 - github.com/sagernet/sing-shadowtls v0.2.1 + github.com/sagernet/sing-shadowsocks v0.2.9-0.20260929204512-65740e0f0e3e + github.com/sagernet/sing-shadowsocks2 v0.2.2-0.20260929152114-a69d1086332b + github.com/sagernet/sing-shadowtls v0.2.2-0.20260928201441-a9c0127d5c99 github.com/sagernet/sing-snell v0.0.0-20260904135315-bc5a12ac736f - github.com/sagernet/sing-tun v0.9.7-0.20260928080829-8ab0e83967b4 + github.com/sagernet/sing-tun v0.9.7-0.20260929152151-0bdadeb4c934 github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca - github.com/sagernet/sing-vmess v0.2.8 + github.com/sagernet/sing-vmess v0.2.9-0.20260929152519-9b95ab8c9478 github.com/sagernet/smux v1.5.50-sing-box-mod.1 github.com/sagernet/tailscale v1.102.1-sing-box-1.14-mod.5 github.com/sagernet/wireguard-go v0.0.7 diff --git a/go.sum b/go.sum index 02e9953b..7988d55b 100644 --- a/go.sum +++ b/go.sum @@ -318,32 +318,32 @@ github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4IC github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 h1:sW2O+DoNF+my1PGlSZ3zG2N+fdxLR3DC0l4sXCM0lBE= github.com/sagernet/quic-go v0.61.0-sing-box-mod.7/go.mod h1:hmLC8GJPp+BrpBgrgJnacvI4fZGIhasuy1ROJJ8GR4E= -github.com/sagernet/sing v0.9.6 h1:LXTpEMbr4G/65PSjopjfg+Zp1Xq1ilJnGWq47y/eObI= -github.com/sagernet/sing v0.9.6/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= -github.com/sagernet/sing-cloudflared v0.1.3 h1:J5v401QFvUzzamw0udgs1kOPgnmPyPjo9jbPUiJgBXo= -github.com/sagernet/sing-cloudflared v0.1.3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= -github.com/sagernet/sing-mux v0.3.9 h1:fhk1tA1OHkDXHedXLanyu/mUepykCXENM9s6sEymo84= -github.com/sagernet/sing-mux v0.3.9/go.mod h1:jricpwCOndDsTEM1TM3piJvhL/2Mm7OhLkm+JadJJ1Q= -github.com/sagernet/sing-openconnect v0.1.0 h1:nen3Hhr52CHASJtduUwZfMhqgLD30A1LE+SN6Ju2X0Y= -github.com/sagernet/sing-openconnect v0.1.0/go.mod h1:4AKZLVcvY3r54UaK2Gbnm7aN8pOwdLz+y4EP0QFZ5Eg= -github.com/sagernet/sing-openvpn v0.1.0 h1:bmULMzzDP0EsO0UMkbiiNLIvEIKBlozM+ZD3GiJ2Vtg= -github.com/sagernet/sing-openvpn v0.1.0/go.mod h1:PWX7WygD8jpwfqfaGNySXpJYTn0SOwjBI1BKHHC2+Bw= +github.com/sagernet/sing v0.9.7-0.20260929145605-8d939df03478 h1:c3Jpk13MOAc516zCuqAGBr7ok9UcpGjCjgWWOQslL0w= +github.com/sagernet/sing v0.9.7-0.20260929145605-8d939df03478/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= +github.com/sagernet/sing-cloudflared v0.1.4-0.20260929150702-b3a1e8f3018c h1:36zXD/qnDk7sh/J4zSEJsnHf4W3TYNCbmjo9MNIlrE4= +github.com/sagernet/sing-cloudflared v0.1.4-0.20260929150702-b3a1e8f3018c/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= +github.com/sagernet/sing-mux v0.3.10-0.20260929204512-caf09fe32475 h1:aG0DqHEOjax7DyfE1GTAXZotOg3QGVCz3/8rCSua2kg= +github.com/sagernet/sing-mux v0.3.10-0.20260929204512-caf09fe32475/go.mod h1:jricpwCOndDsTEM1TM3piJvhL/2Mm7OhLkm+JadJJ1Q= +github.com/sagernet/sing-openconnect v0.1.1-0.20260929151225-cbd68c45de58 h1:WOPVlvbw3zGl4IzpRcfqB901blgDKuiplVrpptf/TOY= +github.com/sagernet/sing-openconnect v0.1.1-0.20260929151225-cbd68c45de58/go.mod h1:4AKZLVcvY3r54UaK2Gbnm7aN8pOwdLz+y4EP0QFZ5Eg= +github.com/sagernet/sing-openvpn v0.1.1-0.20260929151220-7c3045dbddce h1:uB2DkIJLZ9waR/ZLBO9IF/ws5BWg0FEegLi90ibhX5I= +github.com/sagernet/sing-openvpn v0.1.1-0.20260929151220-7c3045dbddce/go.mod h1:PWX7WygD8jpwfqfaGNySXpJYTn0SOwjBI1BKHHC2+Bw= github.com/sagernet/sing-quic v0.7.2-0.20261002084117-75c3ac4fa12b h1:2Jx/URJAPt32MEbLmAR5nzH87tO6NQZLRfRFvFqBWuk= github.com/sagernet/sing-quic v0.7.2-0.20261002084117-75c3ac4fa12b/go.mod h1:rJFeVxbEej2iuyeCJ3drKW+p5rOgtLIW3XhZ6ty7Fig= -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 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkGCKNXhbaM= -github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= +github.com/sagernet/sing-shadowsocks v0.2.9-0.20260929204512-65740e0f0e3e h1:LKgE/uuqp/D2kgxYViOz8vf4slaCUfpFywXjtuwD5gU= +github.com/sagernet/sing-shadowsocks v0.2.9-0.20260929204512-65740e0f0e3e/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI= +github.com/sagernet/sing-shadowsocks2 v0.2.2-0.20260929152114-a69d1086332b h1:VH65QNqSHat2b+N3Rlzo5Qe44gRmQGwzY5atuy3D0A8= +github.com/sagernet/sing-shadowsocks2 v0.2.2-0.20260929152114-a69d1086332b/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ= +github.com/sagernet/sing-shadowtls v0.2.2-0.20260928201441-a9c0127d5c99 h1:l/tdZjaoDp3iqEG4NVrFHtphy1+Qjt9bpWAgNbF8iGg= +github.com/sagernet/sing-shadowtls v0.2.2-0.20260928201441-a9c0127d5c99/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= github.com/sagernet/sing-snell v0.0.0-20260904135315-bc5a12ac736f h1:tbTCI+iis5sTmua7a6Q7vebcSRURok/LGgnEafnQAsc= github.com/sagernet/sing-snell v0.0.0-20260904135315-bc5a12ac736f/go.mod h1:et8Lws4f5QbOrY65DmjevHGup3mijJkhswkto6cwciM= -github.com/sagernet/sing-tun v0.9.7-0.20260928080829-8ab0e83967b4 h1:8LMh5gE8agrhOqeaTg9xML99oVeP8ApxqC0uKeS+ruk= -github.com/sagernet/sing-tun v0.9.7-0.20260928080829-8ab0e83967b4/go.mod h1:3EgPst7agntRO7D6GOsiZ1l9FoqdLeuWmKT5TnWkmf0= +github.com/sagernet/sing-tun v0.9.7-0.20260929152151-0bdadeb4c934 h1:CC1NMvo1hpRY33IJCwXldUOSgCLjYh0T4vq9pX30+tM= +github.com/sagernet/sing-tun v0.9.7-0.20260929152151-0bdadeb4c934/go.mod h1:3EgPst7agntRO7D6GOsiZ1l9FoqdLeuWmKT5TnWkmf0= github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca h1:5wA+IE0Fq1CGVLOgSpm0gKKZ03HzugcJ2JyhmDqeX6A= github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca/go.mod h1:ADAZZU85MnM91XNhd2TdZRTaIbA7RjTZuQ1KCHcUbNg= -github.com/sagernet/sing-vmess v0.2.8 h1:xd5nnDOMlC76RgrLksS4jlk3eMt3c3CvQY3NsjWPWeI= -github.com/sagernet/sing-vmess v0.2.8/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY= +github.com/sagernet/sing-vmess v0.2.9-0.20260929152519-9b95ab8c9478 h1:q2eQn4nq8oWGxRm9zXesXSlPr9GEgPrOGzf4iPtzG9A= +github.com/sagernet/sing-vmess v0.2.9-0.20260929152519-9b95ab8c9478/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/tailscale v1.102.1-sing-box-1.14-mod.5 h1:mGLWVgj2Pl2NlgE1+sEUIDIMB42JdWEPwYmxTnVWg44= diff --git a/protocol/bridge/packet.go b/protocol/bridge/packet.go index 47f089e4..0247c2e6 100644 --- a/protocol/bridge/packet.go +++ b/protocol/bridge/packet.go @@ -75,6 +75,9 @@ func fixReturnChecksum(packet []byte) { return } ipHdr := header.IPv6(packet) + if !ipHdr.IsValid(len(packet)) { + return + } recomputeTransportChecksum(ipHdr.TransportProtocol(), ipHdr.Payload(), ipHdr.SourceAddressSlice(), ipHdr.DestinationAddressSlice()) } } @@ -86,6 +89,10 @@ func recomputeTransportChecksum(protocol tcpip.TransportProtocolNumber, transpor return } tcpHdr := header.TCP(transport) + dataOffset := int(tcpHdr.DataOffset()) + if dataOffset < header.TCPMinimumSize || dataOffset > len(transport) { + return + } tcpHdr.SetChecksum(0) payloadChecksum := checksum.Checksum(tcpHdr.Payload(), 0) pseudoChecksum := header.PseudoHeaderChecksum(header.TCPProtocolNumber, source, destination, uint16(len(transport))) diff --git a/protocol/naive/inbound.go b/protocol/naive/inbound.go index 9da0cf08..dfa022b1 100644 --- a/protocol/naive/inbound.go +++ b/protocol/naive/inbound.go @@ -18,6 +18,8 @@ import ( "github.com/sagernet/sing-box/transport/v2rayhttp" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" @@ -184,11 +186,22 @@ func (n *Inbound) ServeHTTP(writer http.ResponseWriter, request *http.Request) { destination := M.ParseSocksaddr(hostPort).Unwrap() if hijacker, isHijacker := writer.(http.Hijacker); isHijacker { - conn, _, err := hijacker.Hijack() + conn, reader, err := hijacker.Hijack() if err != nil { n.badRequest(ctx, request, E.New("hijack failed")) return } + if cacheLen := reader.Reader.Buffered(); cacheLen > 0 { + cache := buf.NewSize(cacheLen) + _, err = cache.ReadFullFrom(reader.Reader, cacheLen) + if err != nil { + cache.Release() + conn.Close() + n.badRequest(ctx, request, E.Cause(err, "read cache")) + return + } + conn = bufio.NewCachedConn(conn, cache) + } n.newConnection(ctx, false, &naiveConn{Conn: conn}, userName, source, destination) } else { n.newConnection(ctx, true, &naiveH2Conn{ diff --git a/protocol/shadowsocks/inbound_multi.go b/protocol/shadowsocks/inbound_multi.go index 706243e2..9233c749 100644 --- a/protocol/shadowsocks/inbound_multi.go +++ b/protocol/shadowsocks/inbound_multi.go @@ -4,6 +4,7 @@ import ( "context" "net" "os" + "sync" "time" "github.com/sagernet/sing-box/adapter" @@ -35,13 +36,14 @@ var ( type MultiInbound struct { inbound.Adapter - ctx context.Context - router adapter.ConnectionRouterEx - logger logger.ContextLogger - listener *listener.Listener - service shadowsocks.MultiService[int] - users []option.ShadowsocksUser - tracker adapter.SSMTracker + ctx context.Context + router adapter.ConnectionRouterEx + logger logger.ContextLogger + listener *listener.Listener + service shadowsocks.MultiService[int] + usersAccess sync.RWMutex + users []option.ShadowsocksUser + tracker adapter.SSMTracker } func newMultiInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ShadowsocksInboundOptions) (*MultiInbound, error) { @@ -123,6 +125,8 @@ func (h *MultiInbound) SetTracker(tracker adapter.SSMTracker) { } func (h *MultiInbound) UpdateUsers(users []string, uPSKs []string) error { + h.usersAccess.Lock() + defer h.usersAccess.Unlock() err := h.service.UpdateUsersWithPasswords(common.MapIndexed(users, func(index int, user string) int { return index }), uPSKs) @@ -137,6 +141,15 @@ func (h *MultiInbound) UpdateUsers(users []string, uPSKs []string) error { return nil } +func (h *MultiInbound) userName(userIndex int) string { + h.usersAccess.RLock() + defer h.usersAccess.RUnlock() + if userIndex >= len(h.users) { + return "" + } + return h.users[userIndex].Name +} + //nolint:staticcheck func (h *MultiInbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { err := h.service.NewConnection(ctx, conn, adapter.UpstreamMetadata(metadata)) @@ -163,7 +176,7 @@ func (h *MultiInbound) newConnection(ctx context.Context, conn net.Conn, metadat if !loaded { return os.ErrInvalid } - user := h.users[userIndex].Name + user := h.userName(userIndex) if user == "" { user = F.ToString(userIndex) } else { @@ -186,7 +199,7 @@ func (h *MultiInbound) newPacketConnection(ctx context.Context, conn N.PacketCon if !loaded { return os.ErrInvalid } - user := h.users[userIndex].Name + user := h.userName(userIndex) if user == "" { user = F.ToString(userIndex) } else { diff --git a/protocol/tailscale/dns_transport.go b/protocol/tailscale/dns_transport.go index b48aa823..fecbf702 100644 --- a/protocol/tailscale/dns_transport.go +++ b/protocol/tailscale/dns_transport.go @@ -186,16 +186,22 @@ func (t *DNSTransport) createResolver(directDialer func() N.Dialer, resolver *dn myDialer = dialer.NewResolveDialer(t.ctx, myDialer, false, "", t.endpoint.queryOptions, 0) } if isHTTPScheme { + if serverURL.Hostname() == "" { + return nil, E.New("missing host in resolver address: ", resolver.Addr) + } serverAddr := M.ParseSocksaddrHostPortStr(serverURL.Hostname(), serverURL.Port()) switch serverURL.Scheme { case "https": if serverAddr.Port == 0 { serverAddr.Port = 443 } - tlsConfig := common.Must1(tls.NewClient(t.ctx, t.logger, serverAddr.AddrString(), option.OutboundTLSOptions{ + tlsConfig, err := tls.NewClient(t.ctx, t.logger, serverAddr.AddrString(), option.OutboundTLSOptions{ Enabled: true, ALPN: []string{http2.NextProtoTLS, "http/1.1"}, - })) + }) + if err != nil { + return nil, E.Cause(err, "create TLS config for resolver ", resolver.Addr) + } return transport.NewHTTPSRaw(t.TransportAdapter, t.logger, myDialer, serverURL, http.Header{}, serverAddr, tlsConfig), nil case "http": if serverAddr.Port == 0 { diff --git a/protocol/tailscale/taildrop.go b/protocol/tailscale/taildrop.go index 8c7f5d2d..78359a6b 100644 --- a/protocol/tailscale/taildrop.go +++ b/protocol/tailscale/taildrop.go @@ -361,7 +361,7 @@ func validateTaildropFileName(name string) error { var ( taildropExtensionSuffix = regexp.MustCompile(`(\.[a-zA-Z0-9]{0,3}[a-zA-Z][a-zA-Z0-9]{0,3})*$`) - taildropNumberSuffix = regexp.MustCompile(` \([0-9]+\)`) + taildropNumberSuffix = regexp.MustCompile(` \([0-9]+\)$`) ) func nextFileName(name string) string { diff --git a/protocol/tailscale/taildrop_send.go b/protocol/tailscale/taildrop_send.go index ef7d8fd4..cdd7d112 100644 --- a/protocol/tailscale/taildrop_send.go +++ b/protocol/tailscale/taildrop_send.go @@ -55,7 +55,7 @@ func (t *Endpoint) SendTaildropFile(ctx context.Context, peerStableID string, fi if !peer.Valid() { return E.New("taildrop: peer not found: ", peerStableID) } - if peer.Hostinfo().OS() == "tvOS" { + if !peer.Hostinfo().Valid() || peer.Hostinfo().OS() == "tvOS" { return E.New("taildrop: peer cannot receive files") } if self.User() != peer.User() && !nodeBackend.PeerHasCap(peer, tailcfg.PeerCapabilityFileSharingTarget) { @@ -187,7 +187,7 @@ func (t *Endpoint) taildropTargets() (canShareFiles bool, targets map[string]boo } targets = make(map[string]bool) for _, peer := range nodeBackend.Peers() { - if !peer.Valid() || peer.Hostinfo().OS() == "tvOS" { + if !peer.Valid() || !peer.Hostinfo().Valid() || peer.Hostinfo().OS() == "tvOS" { continue } if self.User() != peer.User() && !nodeBackend.PeerHasCap(peer, tailcfg.PeerCapabilityFileSharingTarget) { diff --git a/protocol/tailscale/tailssh/server.go b/protocol/tailscale/tailssh/server.go index 476f06b7..8ba3ba95 100644 --- a/protocol/tailscale/tailssh/server.go +++ b/protocol/tailscale/tailssh/server.go @@ -334,6 +334,9 @@ func (s *Server) authenticate(ctx gliderssh.Context, conn gossh.ConnMetadata) (* func (s *Server) evaluatePolicy(policy *tailcfg.SSHPolicy, sshUser string, node tailcfg.NodeView, userProfile tailcfg.UserProfile, srcIP netip.Addr) (*sshConnInfo, error) { now := time.Now() for _, rule := range policy.Rules { + if rule == nil { + continue + } if rule.RuleExpires != nil && now.After(*rule.RuleExpires) { continue } diff --git a/route/route.go b/route/route.go index 62f1c865..8dddb043 100644 --- a/route/route.go +++ b/route/route.go @@ -811,7 +811,6 @@ func (r *Router) actionSniff( } goto finally } - packetBuffers = inputPacketBuffers for { var ( sniffBuffer = buf.NewPacket() diff --git a/transport/openconnect/device_system_stack.go b/transport/openconnect/device_system_stack.go index 2042881e..d97fbb0c 100644 --- a/transport/openconnect/device_system_stack.go +++ b/transport/openconnect/device_system_stack.go @@ -103,8 +103,14 @@ func (d *systemStackDevice) writeBuffers(packetBuffers []*buf.Buffer) error { func packetDestination(packet []byte) netip.Addr { switch header.IPVersion(packet) { case header.IPv4Version: + if len(packet) < header.IPv4MinimumSize { + return netip.Addr{} + } return header.IPv4(packet).DestinationAddr() case header.IPv6Version: + if len(packet) < header.IPv6MinimumSize { + return netip.Addr{} + } return header.IPv6(packet).DestinationAddr() default: return netip.Addr{} diff --git a/transport/openvpn/device_system_stack.go b/transport/openvpn/device_system_stack.go index d0d9a977..41cad658 100644 --- a/transport/openvpn/device_system_stack.go +++ b/transport/openvpn/device_system_stack.go @@ -104,8 +104,14 @@ func (d *systemStackDevice) writeBuffers(packetBuffers []*buf.Buffer) error { func packetDestination(packet []byte) netip.Addr { switch header.IPVersion(packet) { case header.IPv4Version: + if len(packet) < header.IPv4MinimumSize { + return netip.Addr{} + } return header.IPv4(packet).DestinationAddr() case header.IPv6Version: + if len(packet) < header.IPv6MinimumSize { + return netip.Addr{} + } return header.IPv6(packet).DestinationAddr() default: return netip.Addr{} diff --git a/transport/trojan/mux.go b/transport/trojan/mux.go index 72d5a776..e1d98813 100644 --- a/transport/trojan/mux.go +++ b/transport/trojan/mux.go @@ -60,8 +60,9 @@ func newMuxConnection0(ctx context.Context, conn net.Conn, source M.Socksaddr, h } if reader.Buffered() > 0 { buffer := buf.NewSize(reader.Buffered()) - _, err = buffer.ReadFullFrom(reader, buffer.Len()) + _, err = buffer.ReadFullFrom(reader, buffer.FreeLen()) if err != nil { + buffer.Release() return err } conn = bufio.NewCachedConn(conn, buffer) diff --git a/transport/v2raygrpclite/conn.go b/transport/v2raygrpclite/conn.go index 52911e62..7080d63c 100644 --- a/transport/v2raygrpclite/conn.go +++ b/transport/v2raygrpclite/conn.go @@ -29,7 +29,7 @@ type GunConn struct { create chan struct{} err error cancel context.CancelFunc - readRemaining int + readRemaining uint64 onClose func() } @@ -73,11 +73,11 @@ func (c *GunConn) read(b []byte) (n int, err error) { } if c.readRemaining > 0 { - if len(b) > c.readRemaining { + if uint64(len(b)) > c.readRemaining { b = b[:c.readRemaining] } n, err = c.reader.Read(b) - c.readRemaining -= n + c.readRemaining -= uint64(n) return } @@ -91,14 +91,13 @@ func (c *GunConn) read(b []byte) (n int, err error) { return } - readLen := int(dataLen) - c.readRemaining = readLen - if len(b) > readLen { - b = b[:readLen] + c.readRemaining = dataLen + if uint64(len(b)) > dataLen { + b = b[:dataLen] } n, err = c.reader.Read(b) - c.readRemaining -= n + c.readRemaining -= uint64(n) return } diff --git a/transport/v2rayhttp/server.go b/transport/v2rayhttp/server.go index de03c40c..0f3abcc5 100644 --- a/transport/v2rayhttp/server.go +++ b/transport/v2rayhttp/server.go @@ -105,11 +105,16 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { source := sHttp.SourceAddress(request) if h, ok := writer.(http.Hijacker); ok { + if request.ContentLength > buf.MaxPooledBufferSize { + s.invalidRequest(writer, request, http.StatusRequestEntityTooLarge, E.New("request body too large: ", request.ContentLength)) + return + } var requestBody *buf.Buffer if contentLength := int(request.ContentLength); contentLength > 0 { requestBody = buf.NewSize(contentLength) _, err := requestBody.ReadFullFrom(request.Body, contentLength) if err != nil { + requestBody.Release() s.invalidRequest(writer, request, 0, E.Cause(err, "read request")) return } @@ -118,6 +123,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { writer.(http.Flusher).Flush() conn, reader, err := h.Hijack() if err != nil { + requestBody.Release() s.invalidRequest(writer, request, 0, E.Cause(err, "hijack conn")) return } @@ -125,6 +131,8 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { cache := buf.NewSize(cacheLen) _, err = cache.ReadFullFrom(reader.Reader, cacheLen) if err != nil { + cache.Release() + requestBody.Release() conn.Close() s.invalidRequest(writer, request, 0, E.Cause(err, "read cache")) return diff --git a/transport/v2rayhttpupgrade/client.go b/transport/v2rayhttpupgrade/client.go index 27ef8758..ea4c425b 100644 --- a/transport/v2rayhttpupgrade/client.go +++ b/transport/v2rayhttpupgrade/client.go @@ -105,8 +105,9 @@ func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { } if bufReader.Buffered() > 0 { buffer := buf.NewSize(bufReader.Buffered()) - _, err = buffer.ReadFullFrom(bufReader, buffer.Len()) + _, err = buffer.ReadFullFrom(bufReader, buffer.FreeLen()) if err != nil { + buffer.Release() conn.Close() return nil, err } diff --git a/transport/v2rayhttpupgrade/server.go b/transport/v2rayhttpupgrade/server.go index 338b7248..2b58bc2a 100644 --- a/transport/v2rayhttpupgrade/server.go +++ b/transport/v2rayhttpupgrade/server.go @@ -14,6 +14,8 @@ import ( "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/transport/v2rayhttp" "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" @@ -107,11 +109,22 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { s.invalidRequest(writer, request, http.StatusInternalServerError, E.New("invalid connection, maybe HTTP/2")) return } - conn, _, err := hijacker.Hijack() + conn, reader, err := hijacker.Hijack() if err != nil { s.invalidRequest(writer, request, http.StatusInternalServerError, E.Cause(err, "hijack failed")) return } + if cacheLen := reader.Reader.Buffered(); cacheLen > 0 { + cache := buf.NewSize(cacheLen) + _, err = cache.ReadFullFrom(reader.Reader, cacheLen) + if err != nil { + cache.Release() + conn.Close() + s.invalidRequest(writer, request, 0, E.Cause(err, "read cache")) + return + } + conn = bufio.NewCachedConn(conn, cache) + } s.handler.NewConnectionEx(v2rayhttp.DupContext(request.Context()), conn, sHttp.SourceAddress(request), M.Socksaddr{}, nil) } diff --git a/transport/v2raywebsocket/client.go b/transport/v2raywebsocket/client.go index d1bce75e..54d748f1 100644 --- a/transport/v2raywebsocket/client.go +++ b/transport/v2raywebsocket/client.go @@ -97,8 +97,10 @@ func (c *Client) upgrade(conn net.Conn, requestURL *url.URL, headers http.Header } deadlineConn.SetDeadline(time.Now().Add(C.TCPTimeout)) var protocols []string - if protocolHeader := headers.Get("Sec-WebSocket-Protocol"); protocolHeader != "" { + protocolHeader := headers.Get("Sec-WebSocket-Protocol") + if protocolHeader != "" { protocols = []string{protocolHeader} + headers = headers.Clone() headers.Del("Sec-WebSocket-Protocol") } reader, _, err := ws.Dialer{Header: ws.HandshakeHeaderHTTP(headers), Protocols: protocols}.Upgrade(deadlineConn, requestURL) @@ -109,7 +111,7 @@ func (c *Client) upgrade(conn net.Conn, requestURL *url.URL, headers http.Header } if reader != nil { buffer := buf.NewSize(reader.Buffered()) - _, err = buffer.ReadFullFrom(reader, buffer.Len()) + _, err = buffer.ReadFullFrom(reader, buffer.FreeLen()) if err != nil { conn.Close() return nil, err diff --git a/transport/v2raywebsocket/conn.go b/transport/v2raywebsocket/conn.go index ef277233..ecea3c55 100644 --- a/transport/v2raywebsocket/conn.go +++ b/transport/v2raywebsocket/conn.go @@ -29,7 +29,13 @@ type WebsocketConn struct { } func NewConn(conn net.Conn, remoteAddr net.Addr, state ws.State) *WebsocketConn { - controlHandler := wsutil.ControlFrameHandler(conn, state) + defaultControlHandler := wsutil.ControlFrameHandler(conn, state) + controlHandler := func(header ws.Header, reader io.Reader) error { + if header.Length > ws.MaxControlFramePayloadSize { + return ws.ErrProtocolControlPayloadOverflow + } + return defaultControlHandler(header, reader) + } return &WebsocketConn{ Conn: conn, state: state, @@ -75,10 +81,6 @@ func (c *WebsocketConn) Read(b []byte) (n int, err error) { return } if header.OpCode.IsControl() { - if header.Length > 128 { - err = wsutil.ErrFrameTooLarge - return - } err = wrapWsError(c.controlHandler(header, c.reader)) if err != nil { return From bff5acf292d8ce6109321065586c7e8551e89ca3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 29 Sep 2026 23:28:17 +0800 Subject: [PATCH 56/66] Handle connected UDP EOF on BSD --- common/dialer/default.go | 9 +++++++ common/dialer/udp_conn.go | 53 +++++++++++++++++++++++++++++++++++++++ go.mod | 6 ++--- go.sum | 12 ++++----- 4 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 common/dialer/udp_conn.go diff --git a/common/dialer/default.go b/common/dialer/default.go index 25b264ff..f202283c 100644 --- a/common/dialer/default.go +++ b/common/dialer/default.go @@ -423,6 +423,15 @@ func (d *DefaultDialer) trackConn(ctx context.Context, destination M.Socksaddr, if err != nil { return conn, err } + if nativeConn, isUDPConn := conn.(*net.UDPConn); isUDPConn { + var rawConn syscall.RawConn + rawConn, err = nativeConn.SyscallConn() + if err != nil { + conn.Close() + return nil, err + } + conn = &udpConn{Conn: conn, rawConn: rawConn} + } if d.connectionManager != nil { conn = d.connectionManager.TrackConn(conn) } diff --git a/common/dialer/udp_conn.go b/common/dialer/udp_conn.go new file mode 100644 index 00000000..e3dc5e41 --- /dev/null +++ b/common/dialer/udp_conn.go @@ -0,0 +1,53 @@ +package dialer + +import ( + "io" + "net" + "syscall" + + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/control" + N "github.com/sagernet/sing/common/network" +) + +type udpConn struct { + net.Conn + rawConn syscall.RawConn +} + +func (c *udpConn) Read(b []byte) (n int, err error) { + n, err = c.Conn.Read(b) + if n == 0 && err == nil { + var isEOF bool + c.rawConn.Control(func(fd uintptr) { + isEOF = control.IsUDPEOF(fd) + }) + if isEOF { + err = io.EOF + } + } + return +} + +func (c *udpConn) SyscallConn() (syscall.RawConn, error) { + return c.rawConn, nil +} + +func (c *udpConn) Upstream() any { + return c.Conn +} + +func (c *udpConn) ReaderReplaceable() bool { + return true +} + +func (c *udpConn) WriterReplaceable() bool { + return true +} + +var ( + _ syscall.Conn = (*udpConn)(nil) + _ common.WithUpstream = (*udpConn)(nil) + _ N.ReaderWithUpstream = (*udpConn)(nil) + _ N.WriterWithUpstream = (*udpConn)(nil) +) diff --git a/go.mod b/go.mod index 56567a99..35df2eae 100644 --- a/go.mod +++ b/go.mod @@ -44,8 +44,8 @@ require ( github.com/sagernet/gvisor v0.0.0-20260727.0-sing-box-mod.1 github.com/sagernet/netlink v0.0.0-20260814022025-64455d367bbf github.com/sagernet/nftables v0.3.0-mod.4 - github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 - github.com/sagernet/sing v0.9.7-0.20260929145605-8d939df03478 + github.com/sagernet/quic-go v0.61.0-sing-box-mod.9 + github.com/sagernet/sing v0.9.7-0.20260929150544-0ad23b637bd4 github.com/sagernet/sing-cloudflared v0.1.4-0.20260929150702-b3a1e8f3018c github.com/sagernet/sing-mux v0.3.10-0.20260929204512-caf09fe32475 github.com/sagernet/sing-openconnect v0.1.1-0.20260929151225-cbd68c45de58 @@ -60,7 +60,7 @@ require ( github.com/sagernet/sing-vmess v0.2.9-0.20260929152519-9b95ab8c9478 github.com/sagernet/smux v1.5.50-sing-box-mod.1 github.com/sagernet/tailscale v1.102.1-sing-box-1.14-mod.5 - github.com/sagernet/wireguard-go v0.0.7 + github.com/sagernet/wireguard-go v0.0.8-0.20260929150556-6731c7387c81 github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.12.0 diff --git a/go.sum b/go.sum index 7988d55b..481f6c6a 100644 --- a/go.sum +++ b/go.sum @@ -316,10 +316,10 @@ github.com/sagernet/netlink v0.0.0-20260814022025-64455d367bbf h1:b/3rm+KvxzuYRc github.com/sagernet/netlink v0.0.0-20260814022025-64455d367bbf/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4ICS2nhlDSo= github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= -github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 h1:sW2O+DoNF+my1PGlSZ3zG2N+fdxLR3DC0l4sXCM0lBE= -github.com/sagernet/quic-go v0.61.0-sing-box-mod.7/go.mod h1:hmLC8GJPp+BrpBgrgJnacvI4fZGIhasuy1ROJJ8GR4E= -github.com/sagernet/sing v0.9.7-0.20260929145605-8d939df03478 h1:c3Jpk13MOAc516zCuqAGBr7ok9UcpGjCjgWWOQslL0w= -github.com/sagernet/sing v0.9.7-0.20260929145605-8d939df03478/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= +github.com/sagernet/quic-go v0.61.0-sing-box-mod.9 h1:Lojx77uC63TxZO38Z1OSo6aOoBSXY+ueH0/IKKeTZuI= +github.com/sagernet/quic-go v0.61.0-sing-box-mod.9/go.mod h1:hmLC8GJPp+BrpBgrgJnacvI4fZGIhasuy1ROJJ8GR4E= +github.com/sagernet/sing v0.9.7-0.20260929150544-0ad23b637bd4 h1:d4WT3KO4zhp9qzPHUZCKqCP+ZmP1GXBsAmReqlnE4zA= +github.com/sagernet/sing v0.9.7-0.20260929150544-0ad23b637bd4/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= github.com/sagernet/sing-cloudflared v0.1.4-0.20260929150702-b3a1e8f3018c h1:36zXD/qnDk7sh/J4zSEJsnHf4W3TYNCbmjo9MNIlrE4= github.com/sagernet/sing-cloudflared v0.1.4-0.20260929150702-b3a1e8f3018c/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.10-0.20260929204512-caf09fe32475 h1:aG0DqHEOjax7DyfE1GTAXZotOg3QGVCz3/8rCSua2kg= @@ -348,8 +348,8 @@ github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1h github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= github.com/sagernet/tailscale v1.102.1-sing-box-1.14-mod.5 h1:mGLWVgj2Pl2NlgE1+sEUIDIMB42JdWEPwYmxTnVWg44= github.com/sagernet/tailscale v1.102.1-sing-box-1.14-mod.5/go.mod h1:WLUSOPmTcf7VN9gLCe01qUSIvD+/cKC177neENyZPkI= -github.com/sagernet/wireguard-go v0.0.7 h1:FmP7QY5OcFlhKtDVB81Xd+uoBv4BjW5GORBTUYwpuu0= -github.com/sagernet/wireguard-go v0.0.7/go.mod h1:er10sELpmzLXq7S7Pbc1Zsbyapcr+/gxNAHKTo6fzVA= +github.com/sagernet/wireguard-go v0.0.8-0.20260929150556-6731c7387c81 h1:VsrjoaljEz7jQ1LWbrNyf/scQSQIkVVp7v4nSDrPJ+I= +github.com/sagernet/wireguard-go v0.0.8-0.20260929150556-6731c7387c81/go.mod h1:er10sELpmzLXq7S7Pbc1Zsbyapcr+/gxNAHKTo6fzVA= 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/smallstep/pkcs7 v0.1.1 h1:x+rPdt2W088V9Vkjho4KtoggyktZJlMduZAtRHm68LU= From a364ff4794f0f8715ff26b11dc52402771415525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 1 Oct 2026 22:51:07 +0800 Subject: [PATCH 57/66] Remove unimplemented hot reload from managers --- adapter/certificate/manager.go | 56 ++--------------- adapter/certificate_provider.go | 1 - adapter/dns.go | 1 - adapter/endpoint.go | 1 - adapter/endpoint/manager.go | 55 ++--------------- adapter/inbound.go | 1 - adapter/inbound/manager.go | 55 ++--------------- adapter/lifecycle_legacy.go | 52 ---------------- adapter/outbound.go | 1 - adapter/outbound/manager.go | 87 +++------------------------ adapter/service.go | 1 - adapter/service/manager.go | 55 ++--------------- box.go | 21 ++++++- common/tls/std_server.go | 2 +- dns/router_race_test.go | 4 -- dns/transport_manager.go | 84 ++------------------------ protocol/group/urltest.go | 34 +++++------ route/rule/rule_item_rule_set_test.go | 2 - 18 files changed, 65 insertions(+), 448 deletions(-) delete mode 100644 adapter/lifecycle_legacy.go diff --git a/adapter/certificate/manager.go b/adapter/certificate/manager.go index e4b9b535..55cd7c03 100644 --- a/adapter/certificate/manager.go +++ b/adapter/certificate/manager.go @@ -2,7 +2,6 @@ package certificate import ( "context" - "os" "sync" "time" @@ -10,7 +9,6 @@ import ( "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" F "github.com/sagernet/sing/common/format" ) @@ -48,7 +46,7 @@ func (m *Manager) Start(stage adapter.StartStage) error { name := "certificate-provider/" + provider.Type() + "[" + provider.Tag() + "]" m.logger.Trace(stage, " ", name) startTime := time.Now() - err := adapter.LegacyStart(provider, stage) + err := provider.Start(stage) if err != nil { return E.Cause(err, stage, " ", name) } @@ -95,29 +93,6 @@ func (m *Manager) Get(tag string) (adapter.CertificateProviderService, bool) { 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.CertificateProviderService) bool { - return it == provider - }) - if index == -1 { - panic("invalid certificate provider index") - } - m.providers = append(m.providers[:index], m.providers[index+1:]...) - started := m.started - m.access.Unlock() - if started { - return provider.Close() - } - return nil -} - func (m *Manager) Create(ctx context.Context, logger log.ContextLogger, tag string, providerType string, options any) error { provider, err := m.registry.Create(ctx, logger, tag, providerType, options) if err != nil { @@ -125,32 +100,9 @@ func (m *Manager) Create(ctx context.Context, logger log.ContextLogger, tag stri } m.access.Lock() defer m.access.Unlock() - if m.started { - name := "certificate-provider/" + provider.Type() + "[" + provider.Tag() + "]" - for _, stage := range adapter.ListStartStages { - m.logger.Trace(stage, " ", name) - startTime := time.Now() - err = adapter.LegacyStart(provider, stage) - if err != nil { - return E.Cause(err, stage, " ", name) - } - m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") - } - } - if existsProvider, loaded := m.providerByTag[tag]; loaded { - if m.started { - err = existsProvider.Close() - if err != nil { - return E.Cause(err, "close certificate-provider/", existsProvider.Type(), "[", existsProvider.Tag(), "]") - } - } - existsIndex := common.Index(m.providers, func(it adapter.CertificateProviderService) bool { - return it == existsProvider - }) - if existsIndex == -1 { - panic("invalid certificate provider index") - } - m.providers = append(m.providers[:existsIndex], m.providers[existsIndex+1:]...) + _, loaded := m.providerByTag[tag] + if loaded { + return E.New("duplicate certificate provider tag: ", tag) } m.providers = append(m.providers, provider) m.providerByTag[tag] = provider diff --git a/adapter/certificate_provider.go b/adapter/certificate_provider.go index 70bdeb88..b6b1e2f3 100644 --- a/adapter/certificate_provider.go +++ b/adapter/certificate_provider.go @@ -33,6 +33,5 @@ type CertificateProviderManager interface { Lifecycle CertificateProviders() []CertificateProviderService Get(tag string) (CertificateProviderService, bool) - Remove(tag string) error Create(ctx context.Context, logger log.ContextLogger, tag string, providerType string, options any) error } diff --git a/adapter/dns.go b/adapter/dns.go index 15df285d..8119097e 100644 --- a/adapter/dns.go +++ b/adapter/dns.go @@ -89,6 +89,5 @@ type DNSTransportManager interface { Transport(tag string) (DNSTransport, bool) Default() DNSTransport FakeIP() FakeIPTransport - Remove(tag string) error Create(ctx context.Context, logger log.ContextLogger, tag string, outboundType string, options any) error } diff --git a/adapter/endpoint.go b/adapter/endpoint.go index f09f08ce..de416e23 100644 --- a/adapter/endpoint.go +++ b/adapter/endpoint.go @@ -23,6 +23,5 @@ type EndpointManager interface { Lifecycle Endpoints() []Endpoint Get(tag string) (Endpoint, bool) - Remove(tag string) error Create(ctx context.Context, router Router, logger log.ContextLogger, tag string, endpointType string, options any) error } diff --git a/adapter/endpoint/manager.go b/adapter/endpoint/manager.go index 535c31b4..75487538 100644 --- a/adapter/endpoint/manager.go +++ b/adapter/endpoint/manager.go @@ -2,14 +2,12 @@ package endpoint import ( "context" - "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" ) @@ -48,7 +46,7 @@ func (m *Manager) Start(stage adapter.StartStage) error { for _, endpoint := range m.endpoints { name := "endpoint/" + endpoint.Type() + "[" + endpoint.Tag() + "]" done := adapter.LogElapsed(m.logger, stage, " ", name) - err := adapter.LegacyStart(endpoint, stage) + err := endpoint.Start(stage) done() if err != nil { return E.Cause(err, stage, " ", name) @@ -94,29 +92,6 @@ func (m *Manager) Get(tag string) (adapter.Endpoint, bool) { return endpoint, found } -func (m *Manager) Remove(tag string) error { - m.access.Lock() - endpoint, found := m.endpointByTag[tag] - if !found { - m.access.Unlock() - return os.ErrInvalid - } - delete(m.endpointByTag, tag) - index := common.Index(m.endpoints, func(it adapter.Endpoint) bool { - return it == endpoint - }) - if index == -1 { - panic("invalid endpoint index") - } - m.endpoints = append(m.endpoints[:index], m.endpoints[index+1:]...) - started := m.started - m.access.Unlock() - if started { - return endpoint.Close() - } - return nil -} - func (m *Manager) Create(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, outboundType string, options any) error { endpoint, err := m.registry.Create(ctx, router, logger, tag, outboundType, options) if err != nil { @@ -124,31 +99,9 @@ func (m *Manager) Create(ctx context.Context, router adapter.Router, logger log. } m.access.Lock() defer m.access.Unlock() - if m.started { - name := "endpoint/" + endpoint.Type() + "[" + endpoint.Tag() + "]" - for _, stage := range adapter.ListStartStages { - done := adapter.LogElapsed(m.logger, stage, " ", name) - err = adapter.LegacyStart(endpoint, stage) - done() - if err != nil { - return E.Cause(err, stage, " ", name) - } - } - } - if existsEndpoint, loaded := m.endpointByTag[tag]; loaded { - if m.started { - err = existsEndpoint.Close() - if err != nil { - return E.Cause(err, "close endpoint/", existsEndpoint.Type(), "[", existsEndpoint.Tag(), "]") - } - } - existsIndex := common.Index(m.endpoints, func(it adapter.Endpoint) bool { - return it == existsEndpoint - }) - if existsIndex == -1 { - panic("invalid endpoint index") - } - m.endpoints = append(m.endpoints[:existsIndex], m.endpoints[existsIndex+1:]...) + _, loaded := m.endpointByTag[tag] + if loaded { + return E.New("duplicate endpoint tag: ", tag) } m.endpoints = append(m.endpoints, endpoint) m.endpointByTag[tag] = endpoint diff --git a/adapter/inbound.go b/adapter/inbound.go index 954ec9c4..b081ddc9 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -40,7 +40,6 @@ type InboundManager interface { Lifecycle Inbounds() []Inbound Get(tag string) (Inbound, bool) - Remove(tag string) error Create(ctx context.Context, router Router, logger log.ContextLogger, tag string, inboundType string, options any) error } diff --git a/adapter/inbound/manager.go b/adapter/inbound/manager.go index d6567cde..74c918d7 100644 --- a/adapter/inbound/manager.go +++ b/adapter/inbound/manager.go @@ -2,14 +2,12 @@ package inbound import ( "context" - "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" ) @@ -47,7 +45,7 @@ func (m *Manager) Start(stage adapter.StartStage) error { for _, inbound := range inbounds { name := "inbound/" + inbound.Type() + "[" + inbound.Tag() + "]" done := adapter.LogElapsed(m.logger, stage, " ", name) - err := adapter.LegacyStart(inbound, stage) + err := inbound.Start(stage) done() if err != nil { return E.Cause(err, stage, " ", name) @@ -96,29 +94,6 @@ func (m *Manager) Get(tag string) (adapter.Inbound, bool) { return m.endpoint.Get(tag) } -func (m *Manager) Remove(tag string) error { - m.access.Lock() - inbound, found := m.inboundByTag[tag] - if !found { - m.access.Unlock() - return os.ErrInvalid - } - delete(m.inboundByTag, tag) - index := common.Index(m.inbounds, func(it adapter.Inbound) bool { - return it == inbound - }) - if index == -1 { - panic("invalid inbound index") - } - m.inbounds = append(m.inbounds[:index], m.inbounds[index+1:]...) - started := m.started - m.access.Unlock() - if started { - return inbound.Close() - } - return nil -} - func (m *Manager) Create(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, outboundType string, options any) error { inbound, err := m.registry.Create(ctx, router, logger, tag, outboundType, options) if err != nil { @@ -126,31 +101,9 @@ func (m *Manager) Create(ctx context.Context, router adapter.Router, logger log. } m.access.Lock() defer m.access.Unlock() - if m.started { - name := "inbound/" + inbound.Type() + "[" + inbound.Tag() + "]" - for _, stage := range adapter.ListStartStages { - done := adapter.LogElapsed(m.logger, stage, " ", name) - err = adapter.LegacyStart(inbound, stage) - done() - if err != nil { - return E.Cause(err, stage, " ", name) - } - } - } - if existsInbound, loaded := m.inboundByTag[tag]; loaded { - if m.started { - err = existsInbound.Close() - if err != nil { - return E.Cause(err, "close inbound/", existsInbound.Type(), "[", existsInbound.Tag(), "]") - } - } - existsIndex := common.Index(m.inbounds, func(it adapter.Inbound) bool { - return it == existsInbound - }) - if existsIndex == -1 { - panic("invalid inbound index") - } - m.inbounds = append(m.inbounds[:existsIndex], m.inbounds[existsIndex+1:]...) + _, loaded := m.inboundByTag[tag] + if loaded { + return E.New("duplicate inbound tag: ", tag) } m.inbounds = append(m.inbounds, inbound) m.inboundByTag[tag] = inbound diff --git a/adapter/lifecycle_legacy.go b/adapter/lifecycle_legacy.go deleted file mode 100644 index f8b25db6..00000000 --- a/adapter/lifecycle_legacy.go +++ /dev/null @@ -1,52 +0,0 @@ -package adapter - -func LegacyStart(starter any, stage StartStage) error { - if lifecycle, isLifecycle := starter.(Lifecycle); isLifecycle { - return lifecycle.Start(stage) - } - switch stage { - case StartStateInitialize: - if preStarter, isPreStarter := starter.(interface { - PreStart() error - }); isPreStarter { - return preStarter.PreStart() - } - case StartStateStart: - if starter, isStarter := starter.(interface { - Start() error - }); isStarter { - return starter.Start() - } - case StartStateStarted: - if postStarter, isPostStarter := starter.(interface { - PostStart() error - }); isPostStarter { - return postStarter.PostStart() - } - } - return nil -} - -type lifecycleServiceWrapper struct { - SimpleLifecycle - name string -} - -func NewLifecycleService(service SimpleLifecycle, name string) LifecycleService { - return &lifecycleServiceWrapper{ - SimpleLifecycle: service, - name: name, - } -} - -func (l *lifecycleServiceWrapper) Name() string { - return l.name -} - -func (l *lifecycleServiceWrapper) Start(stage StartStage) error { - return LegacyStart(l.SimpleLifecycle, stage) -} - -func (l *lifecycleServiceWrapper) Close() error { - return l.SimpleLifecycle.Close() -} diff --git a/adapter/outbound.go b/adapter/outbound.go index 73d93ddb..d8783cbe 100644 --- a/adapter/outbound.go +++ b/adapter/outbound.go @@ -47,7 +47,6 @@ type OutboundManager interface { Outbounds() []Outbound Outbound(tag string) (Outbound, bool) Default() Outbound - Remove(tag string) error Create(ctx context.Context, router Router, logger log.ContextLogger, tag string, outboundType string, options any) error } diff --git a/adapter/outbound/manager.go b/adapter/outbound/manager.go index 5314549d..5354bc9d 100644 --- a/adapter/outbound/manager.go +++ b/adapter/outbound/manager.go @@ -28,7 +28,6 @@ type Manager struct { stage adapter.StartStage outbounds []adapter.Outbound outboundByTag map[string]adapter.Outbound - dependByTag map[string][]string defaultOutbound adapter.Outbound defaultOutboundFallback func() (adapter.Outbound, error) } @@ -40,7 +39,6 @@ func NewManager(logger logger.ContextLogger, registry adapter.OutboundRegistry, endpoint: endpoint, defaultTag: defaultTag, outboundByTag: make(map[string]adapter.Outbound), - dependByTag: make(map[string][]string), } } @@ -81,9 +79,13 @@ func (m *Manager) Start(stage adapter.StartStage) error { return m.startOutbounds(append(outbounds, common.Map(m.endpoint.Endpoints(), func(it adapter.Endpoint) adapter.Outbound { return it })...)) } for _, outbound := range outbounds { + starter, isStarter := outbound.(adapter.Lifecycle) + if !isStarter { + continue + } name := "outbound/" + outbound.Type() + "[" + outbound.Tag() + "]" done := adapter.LogElapsed(m.logger, stage, " ", name) - err := adapter.LegacyStart(outbound, stage) + err := starter.Start(stage) done() if err != nil { return E.Cause(err, stage, " ", name) @@ -213,50 +215,6 @@ func (m *Manager) Default() adapter.Outbound { return m.defaultOutbound } -func (m *Manager) Remove(tag string) error { - m.access.Lock() - defer m.access.Unlock() - outbound, found := m.outboundByTag[tag] - if !found { - return os.ErrInvalid - } - delete(m.outboundByTag, tag) - index := common.Index(m.outbounds, func(it adapter.Outbound) bool { - return it == outbound - }) - if index == -1 { - panic("invalid inbound index") - } - m.outbounds = append(m.outbounds[:index], m.outbounds[index+1:]...) - started := m.started - if m.defaultOutbound == outbound { - if len(m.outbounds) > 0 { - m.defaultOutbound = m.outbounds[0] - m.logger.Info("updated default outbound to ", m.defaultOutbound.Tag()) - } else { - m.defaultOutbound = nil - } - } - dependBy := m.dependByTag[tag] - if len(dependBy) > 0 { - return E.New("outbound[", tag, "] is depended by ", strings.Join(dependBy, ", ")) - } - dependencies := outbound.Dependencies() - for _, dependency := range dependencies { - if len(m.dependByTag[dependency]) == 1 { - delete(m.dependByTag, dependency) - } else { - m.dependByTag[dependency] = common.Filter(m.dependByTag[dependency], func(it string) bool { - return it != tag - }) - } - } - if started { - return common.Close(outbound) - } - return nil -} - func (m *Manager) Create(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, inboundType string, options any) error { if tag == "" { return os.ErrInvalid @@ -265,45 +223,16 @@ func (m *Manager) Create(ctx context.Context, router adapter.Router, logger log. if err != nil { return err } - if m.started { - name := "outbound/" + outbound.Type() + "[" + outbound.Tag() + "]" - for _, stage := range adapter.ListStartStages { - done := adapter.LogElapsed(m.logger, stage, " ", name) - err = adapter.LegacyStart(outbound, stage) - done() - if err != nil { - return E.Cause(err, stage, " ", name) - } - } - } m.access.Lock() defer m.access.Unlock() - if existsOutbound, loaded := m.outboundByTag[tag]; loaded { - if m.started { - err = common.Close(existsOutbound) - if err != nil { - return E.Cause(err, "close outbound/", existsOutbound.Type(), "[", existsOutbound.Tag(), "]") - } - } - existsIndex := common.Index(m.outbounds, func(it adapter.Outbound) bool { - return it == existsOutbound - }) - if existsIndex == -1 { - panic("invalid inbound index") - } - m.outbounds = append(m.outbounds[:existsIndex], m.outbounds[existsIndex+1:]...) + _, loaded := m.outboundByTag[tag] + if loaded { + return E.New("duplicate outbound tag: ", tag) } m.outbounds = append(m.outbounds, outbound) m.outboundByTag[tag] = outbound - dependencies := outbound.Dependencies() - for _, dependency := range dependencies { - m.dependByTag[dependency] = append(m.dependByTag[dependency], tag) - } if tag == m.defaultTag || (m.defaultTag == "" && m.defaultOutbound == nil) { m.defaultOutbound = outbound - if m.started { - m.logger.Info("updated default outbound to ", outbound.Tag()) - } } return nil } diff --git a/adapter/service.go b/adapter/service.go index 534bd7eb..519b0abe 100644 --- a/adapter/service.go +++ b/adapter/service.go @@ -22,6 +22,5 @@ type ServiceManager interface { Lifecycle Services() []Service Get(tag string) (Service, bool) - Remove(tag string) error Create(ctx context.Context, logger log.ContextLogger, tag string, serviceType string, options any) error } diff --git a/adapter/service/manager.go b/adapter/service/manager.go index 1a83c503..22d6740a 100644 --- a/adapter/service/manager.go +++ b/adapter/service/manager.go @@ -2,14 +2,12 @@ package service import ( "context" - "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" ) @@ -45,7 +43,7 @@ func (m *Manager) Start(stage adapter.StartStage) error { for _, service := range services { name := "service/" + service.Type() + "[" + service.Tag() + "]" done := adapter.LogElapsed(m.logger, stage, " ", name) - err := adapter.LegacyStart(service, stage) + err := service.Start(stage) done() if err != nil { return E.Cause(err, stage, " ", name) @@ -91,29 +89,6 @@ func (m *Manager) Get(tag string) (adapter.Service, bool) { return service, found } -func (m *Manager) Remove(tag string) error { - m.access.Lock() - service, found := m.serviceByTag[tag] - if !found { - m.access.Unlock() - return os.ErrInvalid - } - delete(m.serviceByTag, tag) - index := common.Index(m.services, func(it adapter.Service) bool { - return it == service - }) - if index == -1 { - panic("invalid service index") - } - m.services = append(m.services[:index], m.services[index+1:]...) - started := m.started - m.access.Unlock() - if started { - return service.Close() - } - return nil -} - func (m *Manager) Create(ctx context.Context, logger log.ContextLogger, tag string, serviceType string, options any) error { service, err := m.registry.Create(ctx, logger, tag, serviceType, options) if err != nil { @@ -121,31 +96,9 @@ func (m *Manager) Create(ctx context.Context, logger log.ContextLogger, tag stri } m.access.Lock() defer m.access.Unlock() - if m.started { - name := "service/" + service.Type() + "[" + service.Tag() + "]" - for _, stage := range adapter.ListStartStages { - done := adapter.LogElapsed(m.logger, stage, " ", name) - err = adapter.LegacyStart(service, stage) - done() - if err != nil { - return E.Cause(err, stage, " ", name) - } - } - } - if existsService, loaded := m.serviceByTag[tag]; loaded { - if m.started { - err = existsService.Close() - if err != nil { - return E.Cause(err, "close service/", existsService.Type(), "[", existsService.Tag(), "]") - } - } - existsIndex := common.Index(m.services, func(it adapter.Service) bool { - return it == existsService - }) - if existsIndex == -1 { - panic("invalid service index") - } - m.services = append(m.services[:existsIndex], m.services[existsIndex+1:]...) + _, loaded := m.serviceByTag[tag] + if loaded { + return E.New("duplicate service tag: ", tag) } m.services = append(m.services, service) m.serviceByTag[tag] = service diff --git a/box.go b/box.go index f1dbc3bf..ef3d497e 100644 --- a/box.go +++ b/box.go @@ -63,6 +63,7 @@ type Box struct { referenceManager *route.ReferenceManager httpClientService adapter.LifecycleService internalService []adapter.LifecycleService + ntpService *ntp.Service done chan struct{} } @@ -448,6 +449,7 @@ func New(options Options) (*Box, error) { service.MustRegister[adapter.V2RayServer](ctx, v2rayServer) } } + var ntpService *ntp.Service if ntpOptions.Enabled { if ntpOptions.WriteToSystem { err = adapter.CheckSecurityFeature(ctx, "NTP `write_to_system`") @@ -459,7 +461,7 @@ func New(options Options) (*Box, error) { if err != nil { return nil, E.Cause(err, "create NTP service") } - ntpService := ntp.NewService(ntp.Options{ + ntpService = ntp.NewService(ntp.Options{ Context: ctx, Dialer: ntpDialer, Logger: logFactory.NewLogger("ntp"), @@ -468,7 +470,6 @@ func New(options Options) (*Box, error) { WriteToSystem: ntpOptions.WriteToSystem, }) timeService.TimeService = ntpService - internalServices = append(internalServices, adapter.NewLifecycleService(ntpService, "ntp service")) } return &Box{ ctx: ctx, @@ -489,6 +490,7 @@ func New(options Options) (*Box, error) { logFactory: logFactory, logger: logFactory.Logger(), internalService: internalServices, + ntpService: ntpService, done: make(chan struct{}), }, nil } @@ -576,6 +578,14 @@ func (s *Box) start() error { if err != nil { return err } + if s.ntpService != nil { + done := adapter.LogElapsed(s.logger, "start ntp service") + err = s.ntpService.Start() + done() + if err != nil { + return E.Cause(err, "start ntp service") + } + } err = adapter.Start(s.ctx, s.logger, adapter.StartStateStart, s.endpoint) if err != nil { return err @@ -657,6 +667,13 @@ func (s *Box) Close() error { }) done() } + if s.ntpService != nil { + done := adapter.LogElapsed(s.logger, "close ntp service") + err = E.Append(err, s.ntpService.Close(), func(err error) error { + return E.Cause(err, "close ntp service") + }) + done() + } done := adapter.LogElapsed(s.logger, "close logger") err = E.Append(err, s.logFactory.Close(), func(err error) error { return E.Cause(err, "close logger") diff --git a/common/tls/std_server.go b/common/tls/std_server.go index ff4ee1a4..d611f72a 100644 --- a/common/tls/std_server.go +++ b/common/tls/std_server.go @@ -62,7 +62,7 @@ type inlineCertificateProvider struct { func (p *inlineCertificateProvider) Start() error { for _, stage := range adapter.ListStartStages { - err := adapter.LegacyStart(p.provider, stage) + err := p.provider.Start(stage) if err != nil { return err } diff --git a/dns/router_race_test.go b/dns/router_race_test.go index f0611ae0..910f9e91 100644 --- a/dns/router_race_test.go +++ b/dns/router_race_test.go @@ -115,10 +115,6 @@ func (m *fakeDNSTransportManager) FakeIP() adapter.FakeIPTransport { return nil } -func (m *fakeDNSTransportManager) Remove(tag string) error { - return nil -} - func (m *fakeDNSTransportManager) Create(ctx context.Context, logger log.ContextLogger, tag string, outboundType string, options any) error { return nil } diff --git a/dns/transport_manager.go b/dns/transport_manager.go index 873cae82..120357c0 100644 --- a/dns/transport_manager.go +++ b/dns/transport_manager.go @@ -28,7 +28,6 @@ type TransportManager struct { stage adapter.StartStage transports []adapter.DNSTransport transportByTag map[string]adapter.DNSTransport - dependByTag map[string][]string defaultTransport adapter.DNSTransport defaultTransportFallback func() (adapter.DNSTransport, error) fakeIPTransport adapter.FakeIPTransport @@ -41,7 +40,6 @@ func NewTransportManager(logger logger.ContextLogger, registry adapter.DNSTransp outbound: outbound, defaultTag: defaultTag, transportByTag: make(map[string]adapter.DNSTransport), - dependByTag: make(map[string][]string), } } @@ -78,7 +76,7 @@ func (m *TransportManager) Start(stage adapter.StartStage) error { return m.startTransports(transports) } for _, transport := range transports { - err := adapter.LegacyStart(transport, stage) + err := transport.Start(stage) if err != nil { return E.Cause(err, stage, " dns/", transport.Type(), "[", transport.Tag(), "]") } @@ -193,54 +191,6 @@ func (m *TransportManager) FakeIP() adapter.FakeIPTransport { return m.fakeIPTransport } -func (m *TransportManager) Remove(tag string) error { - m.access.Lock() - defer m.access.Unlock() - transport, found := m.transportByTag[tag] - if !found { - return os.ErrInvalid - } - delete(m.transportByTag, tag) - index := common.Index(m.transports, func(it adapter.DNSTransport) bool { - return it == transport - }) - if index == -1 { - panic("invalid inbound index") - } - m.transports = append(m.transports[:index], m.transports[index+1:]...) - started := m.started - if m.defaultTransport == transport { - if len(m.transports) > 0 { - nextTransport := m.transports[0] - if nextTransport.Type() != C.DNSTypeFakeIP { - return E.New("default server cannot be fakeip") - } - m.defaultTransport = nextTransport - m.logger.Info("updated default server to ", m.defaultTransport.Tag()) - } else { - m.defaultTransport = nil - } - } - dependBy := m.dependByTag[tag] - if len(dependBy) > 0 { - return E.New("server[", tag, "] is depended by ", strings.Join(dependBy, ", ")) - } - dependencies := transport.Dependencies() - for _, dependency := range dependencies { - if len(m.dependByTag[dependency]) == 1 { - delete(m.dependByTag, dependency) - } else { - m.dependByTag[dependency] = common.Filter(m.dependByTag[dependency], func(it string) bool { - return it != tag - }) - } - } - if started { - transport.Close() - } - return nil -} - func (m *TransportManager) Create(ctx context.Context, logger log.ContextLogger, tag string, transportType string, options any) error { if tag == "" { return os.ErrInvalid @@ -251,43 +201,17 @@ func (m *TransportManager) Create(ctx context.Context, logger log.ContextLogger, } m.access.Lock() defer m.access.Unlock() - if m.started { - for _, stage := range adapter.ListStartStages { - err = adapter.LegacyStart(transport, stage) - if err != nil { - return E.Cause(err, stage, " dns/", transport.Type(), "[", transport.Tag(), "]") - } - } - } - if existsTransport, loaded := m.transportByTag[tag]; loaded { - if m.started { - err = common.Close(existsTransport) - if err != nil { - return E.Cause(err, "close dns/", existsTransport.Type(), "[", existsTransport.Tag(), "]") - } - } - existsIndex := common.Index(m.transports, func(it adapter.DNSTransport) bool { - return it == existsTransport - }) - if existsIndex == -1 { - panic("invalid inbound index") - } - m.transports = append(m.transports[:existsIndex], m.transports[existsIndex+1:]...) + _, loaded := m.transportByTag[tag] + if loaded { + return E.New("duplicate DNS server tag: ", tag) } m.transports = append(m.transports, transport) m.transportByTag[tag] = transport - dependencies := transport.Dependencies() - for _, dependency := range dependencies { - m.dependByTag[dependency] = append(m.dependByTag[dependency], tag) - } if tag == m.defaultTag || (m.defaultTag == "" && m.defaultTransport == nil) { if transport.Type() == C.DNSTypeFakeIP { return E.New("default server cannot be fakeip") } m.defaultTransport = transport - if m.started { - m.logger.Info("updated default server to ", transport.Tag()) - } } if transport.Type() == C.DNSTypeFakeIP { if m.fakeIPTransport != nil { diff --git a/protocol/group/urltest.go b/protocol/group/urltest.go index a264b2dd..01964896 100644 --- a/protocol/group/urltest.go +++ b/protocol/group/urltest.go @@ -71,25 +71,25 @@ func NewURLTest(ctx context.Context, router adapter.Router, logger log.ContextLo return outbound, nil } -func (s *URLTest) Start() error { - outbounds := make([]adapter.Outbound, 0, len(s.tags)) - for i, tag := range s.tags { - detour, loaded := s.outbound.Outbound(tag) - if !loaded { - return E.New("outbound ", i, " not found: ", tag) +func (s *URLTest) Start(stage adapter.StartStage) error { + switch stage { + case adapter.StartStateStart: + outbounds := make([]adapter.Outbound, 0, len(s.tags)) + for i, tag := range s.tags { + detour, loaded := s.outbound.Outbound(tag) + if !loaded { + return E.New("outbound ", i, " not found: ", tag) + } + outbounds = append(outbounds, detour) } - 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 + } + s.group = group + case adapter.StartStateStarted: + s.group.PostStart() } - 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 - } - s.group = group - return nil -} - -func (s *URLTest) PostStart() error { - s.group.PostStart() return nil } diff --git a/route/rule/rule_item_rule_set_test.go b/route/rule/rule_item_rule_set_test.go index a4e2e58c..61c3208b 100644 --- a/route/rule/rule_item_rule_set_test.go +++ b/route/rule/rule_item_rule_set_test.go @@ -61,8 +61,6 @@ func (s *countingRuleSet) Name() string { return s.name } func (s *countingRuleSet) StartContext(context.Context, *adapter.HTTPStartContext) error { return nil } -func (s *countingRuleSet) PostStart() error { return nil } - func (s *countingRuleSet) Metadata() adapter.RuleSetMetadata { return adapter.RuleSetMetadata{} } func (s *countingRuleSet) ExtractIPSet() []*netipx.IPSet { return nil } From a691a4402375535f0329eb9026ab904cfdc1b2eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 1 Oct 2026 23:26:01 +0800 Subject: [PATCH 58/66] Refactor lifecycle to scoped cleanup --- adapter/certificate/manager.go | 49 +---- adapter/endpoint.go | 1 + adapter/endpoint/manager.go | 48 +---- adapter/inbound/manager.go | 45 +--- adapter/lifecycle.go | 122 ++++++----- adapter/outbound/manager.go | 93 ++------- adapter/service/manager.go | 45 +--- box.go | 217 +++++++++----------- common/certificate/store.go | 75 ++++--- common/httpclient/manager.go | 8 +- common/listener/listener.go | 6 +- common/netns/manager_linux.go | 26 +-- common/netns/manager_stub.go | 6 +- common/netns/manager_test.go | 8 +- common/tls/acme.go | 7 +- common/tls/std_server.go | 13 +- common/trafficcontrol/manager.go | 20 +- dns/router.go | 25 +-- dns/router_race_test.go | 12 +- dns/transport/dhcp/dhcp.go | 75 +++---- dns/transport/fakeip/fakeip.go | 13 +- dns/transport/hosts/hosts.go | 6 +- dns/transport/https.go | 11 +- dns/transport/local/local.go | 30 +-- dns/transport/local/local_resolved_linux.go | 101 ++++----- dns/transport/local/local_shared.go | 26 +-- dns/transport/mdns/mdns.go | 10 +- dns/transport/multiplexer_test.go | 17 +- dns/transport/quic/http3.go | 13 +- dns/transport/quic/quic.go | 7 +- dns/transport/tcp.go | 7 +- dns/transport/tls.go | 7 +- dns/transport/udp.go | 7 +- dns/transport_manager.go | 61 +----- experimental/cachefile/cache.go | 20 +- experimental/clashapi/server.go | 10 +- experimental/clashmode/manager.go | 6 +- experimental/libbox/dns.go | 6 +- experimental/libbox/http.go | 19 +- experimental/v2rayapi/server.go | 45 ++-- go.mod | 2 +- go.sum | 4 +- protocol/anytls/inbound.go | 14 +- protocol/anytls/outbound.go | 9 +- protocol/bridge/backend.go | 30 ++- protocol/bridge/backend_darwin.go | 103 ++++------ protocol/bridge/backend_linux.go | 129 ++++++------ protocol/bridge/backend_windows.go | 55 +++-- protocol/bridge/outbound.go | 8 +- protocol/cloudflare/inbound.go | 13 +- protocol/direct/inbound.go | 13 +- protocol/direct/outbound.go | 13 +- protocol/group/selector.go | 5 +- protocol/group/urltest.go | 9 +- protocol/http/inbound.go | 17 +- protocol/hysteria/inbound.go | 19 +- protocol/hysteria/outbound.go | 10 +- protocol/hysteria2/inbound.go | 19 +- protocol/hysteria2/outbound.go | 10 +- protocol/hysteria2/realm.go | 26 ++- protocol/mixed/inbound.go | 17 +- protocol/naive/inbound.go | 17 +- protocol/naive/outbound.go | 7 +- protocol/openconnect/client.go | 129 ++++++------ protocol/openconnect/dns_transport.go | 82 ++++---- protocol/openconnect/status.go | 11 +- protocol/openvpn/client.go | 117 ++++++----- protocol/openvpn/dns_transport.go | 97 ++++----- protocol/openvpn/server.go | 64 +++--- protocol/openvpn/status.go | 6 +- protocol/redirect/redirect.go | 13 +- protocol/redirect/tproxy.go | 13 +- protocol/shadowsocks/inbound.go | 13 +- protocol/shadowsocks/inbound_multi.go | 13 +- protocol/shadowsocks/inbound_relay.go | 13 +- protocol/shadowsocks/outbound.go | 10 +- protocol/shadowtls/inbound.go | 13 +- protocol/snell/inbound.go | 13 +- protocol/snell/outbound.go | 8 +- protocol/socks/inbound.go | 13 +- protocol/ssh/outbound.go | 10 +- protocol/tailscale/certificate_provider.go | 6 +- protocol/tailscale/dns_transport.go | 81 +++++--- protocol/tailscale/endpoint.go | 127 ++++++------ protocol/tor/outbound.go | 32 +-- protocol/trojan/inbound.go | 20 +- protocol/trojan/outbound.go | 13 +- protocol/tuic/inbound.go | 19 +- protocol/tuic/outbound.go | 10 +- protocol/tun/inbound.go | 76 +++---- protocol/vless/inbound.go | 21 +- protocol/vless/outbound.go | 13 +- protocol/vmess/inbound.go | 22 +- protocol/vmess/outbound.go | 13 +- protocol/wireguard/endpoint.go | 17 +- route/conn.go | 14 +- route/network.go | 88 +++----- route/reference.go | 10 +- route/router.go | 55 +---- route/rule/rule_item_rule_set_test.go | 3 +- service/acme/service.go | 15 +- service/api/dashboard.go | 14 +- service/api/server.go | 71 +++---- service/ccm/service.go | 39 ++-- service/derp/service.go | 12 +- service/ocm/service.go | 52 +++-- service/oomkiller/service.go | 5 +- service/oomkiller/service_darwin.go | 38 ++-- service/oomkiller/service_stub.go | 15 +- service/origin_ca/service.go | 52 ++--- service/resolved/service.go | 51 +++-- service/resolved/transport.go | 40 ++-- service/ssmapi/server.go | 45 ++-- service/usbip/client.go | 13 +- service/usbip/server.go | 13 +- 115 files changed, 1595 insertions(+), 2030 deletions(-) diff --git a/adapter/certificate/manager.go b/adapter/certificate/manager.go index 55cd7c03..339f110b 100644 --- a/adapter/certificate/manager.go +++ b/adapter/certificate/manager.go @@ -3,83 +3,42 @@ package certificate import ( "context" "sync" - "time" "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" E "github.com/sagernet/sing/common/exceptions" - F "github.com/sagernet/sing/common/format" ) var _ adapter.CertificateProviderManager = (*Manager)(nil) type Manager struct { - logger log.ContextLogger registry adapter.CertificateProviderRegistry access sync.Mutex - started bool - stage adapter.StartStage providers []adapter.CertificateProviderService providerByTag map[string]adapter.CertificateProviderService } -func NewManager(logger log.ContextLogger, registry adapter.CertificateProviderRegistry) *Manager { +func NewManager(registry adapter.CertificateProviderRegistry) *Manager { return &Manager{ - logger: logger, registry: registry, providerByTag: make(map[string]adapter.CertificateProviderService), } } -func (m *Manager) Start(stage adapter.StartStage) error { +func (m *Manager) Start(stage adapter.StartStage, scope *adapter.Scope) 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 { name := "certificate-provider/" + provider.Type() + "[" + provider.Tag() + "]" - m.logger.Trace(stage, " ", name) - startTime := time.Now() - err := provider.Start(stage) + err := scope.Start(name, provider, stage) if err != nil { - return E.Cause(err, stage, " ", name) + return err } - m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") } return nil } -func (m *Manager) Close() error { - m.access.Lock() - defer m.access.Unlock() - if !m.started { - return nil - } - m.started = false - providers := m.providers - m.providers = nil - monitor := taskmonitor.New(m.logger, C.StopTimeout) - var err error - for _, provider := range providers { - name := "certificate-provider/" + provider.Type() + "[" + provider.Tag() + "]" - m.logger.Trace("close ", name) - startTime := time.Now() - monitor.Start("close ", name) - err = E.Append(err, provider.Close(), func(err error) error { - return E.Cause(err, "close ", name) - }) - monitor.Finish() - m.logger.Trace("close ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") - } - return err -} - func (m *Manager) CertificateProviders() []adapter.CertificateProviderService { m.access.Lock() defer m.access.Unlock() diff --git a/adapter/endpoint.go b/adapter/endpoint.go index de416e23..63d7ce9d 100644 --- a/adapter/endpoint.go +++ b/adapter/endpoint.go @@ -21,6 +21,7 @@ type EndpointRegistry interface { type EndpointManager interface { Lifecycle + StartEndpoint(endpoint Endpoint) error Endpoints() []Endpoint Get(tag string) (Endpoint, bool) Create(ctx context.Context, router Router, logger log.ContextLogger, tag string, endpointType string, options any) error diff --git a/adapter/endpoint/manager.go b/adapter/endpoint/manager.go index 75487538..c4abc31a 100644 --- a/adapter/endpoint/manager.go +++ b/adapter/endpoint/manager.go @@ -5,8 +5,6 @@ import ( "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" E "github.com/sagernet/sing/common/exceptions" ) @@ -14,69 +12,41 @@ import ( var _ adapter.EndpointManager = (*Manager)(nil) type Manager struct { - logger log.ContextLogger registry adapter.EndpointRegistry access sync.Mutex - started bool - stage adapter.StartStage + scope *adapter.Scope endpoints []adapter.Endpoint endpointByTag map[string]adapter.Endpoint } -func NewManager(logger log.ContextLogger, registry adapter.EndpointRegistry) *Manager { +func NewManager(registry adapter.EndpointRegistry) *Manager { return &Manager{ - logger: logger, registry: registry, endpointByTag: make(map[string]adapter.Endpoint), } } -func (m *Manager) Start(stage adapter.StartStage) error { +func (m *Manager) Start(stage adapter.StartStage, scope *adapter.Scope) error { m.access.Lock() defer m.access.Unlock() - if m.started && m.stage >= stage { - panic("already started") + if stage == adapter.StartStateInitialize { + m.scope = scope } - m.started = true - m.stage = stage if stage == adapter.StartStateStart { - // started with outbound manager return nil } for _, endpoint := range m.endpoints { name := "endpoint/" + endpoint.Type() + "[" + endpoint.Tag() + "]" - done := adapter.LogElapsed(m.logger, stage, " ", name) - err := endpoint.Start(stage) - done() + err := scope.Start(name, endpoint, stage) if err != nil { - return E.Cause(err, stage, " ", name) + return err } } return nil } -func (m *Manager) Close() error { - m.access.Lock() - defer m.access.Unlock() - if !m.started { - return nil - } - m.started = false - endpoints := m.endpoints - m.endpoints = nil - monitor := taskmonitor.New(m.logger, C.StopTimeout) - var err error - for _, endpoint := range endpoints { - name := "endpoint/" + endpoint.Type() + "[" + endpoint.Tag() + "]" - done := adapter.LogElapsed(m.logger, "close ", name) - monitor.Start("close ", name) - err = E.Append(err, endpoint.Close(), func(err error) error { - return E.Cause(err, "close ", name) - }) - monitor.Finish() - done() - } - return nil +func (m *Manager) StartEndpoint(endpoint adapter.Endpoint) error { + return m.scope.Start("endpoint/"+endpoint.Type()+"["+endpoint.Tag()+"]", endpoint, adapter.StartStateStart) } func (m *Manager) Endpoints() []adapter.Endpoint { diff --git a/adapter/inbound/manager.go b/adapter/inbound/manager.go index 74c918d7..bafb15d3 100644 --- a/adapter/inbound/manager.go +++ b/adapter/inbound/manager.go @@ -5,8 +5,6 @@ import ( "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" E "github.com/sagernet/sing/common/exceptions" ) @@ -14,70 +12,35 @@ import ( var _ adapter.InboundManager = (*Manager)(nil) type Manager struct { - logger log.ContextLogger registry adapter.InboundRegistry endpoint adapter.EndpointManager access sync.Mutex - started bool - stage adapter.StartStage inbounds []adapter.Inbound inboundByTag map[string]adapter.Inbound } -func NewManager(logger log.ContextLogger, registry adapter.InboundRegistry, endpoint adapter.EndpointManager) *Manager { +func NewManager(registry adapter.InboundRegistry, endpoint adapter.EndpointManager) *Manager { return &Manager{ - logger: logger, registry: registry, endpoint: endpoint, inboundByTag: make(map[string]adapter.Inbound), } } -func (m *Manager) Start(stage adapter.StartStage) error { +func (m *Manager) Start(stage adapter.StartStage, scope *adapter.Scope) error { m.access.Lock() - if m.started && m.stage >= stage { - panic("already started") - } - m.started = true - m.stage = stage inbounds := m.inbounds m.access.Unlock() for _, inbound := range inbounds { name := "inbound/" + inbound.Type() + "[" + inbound.Tag() + "]" - done := adapter.LogElapsed(m.logger, stage, " ", name) - err := inbound.Start(stage) - done() + err := scope.Start(name, inbound, stage) if err != nil { - return E.Cause(err, stage, " ", name) + return err } } return nil } -func (m *Manager) Close() error { - m.access.Lock() - defer m.access.Unlock() - if !m.started { - return nil - } - m.started = false - inbounds := m.inbounds - m.inbounds = nil - monitor := taskmonitor.New(m.logger, C.StopTimeout) - var err error - for _, inbound := range inbounds { - name := "inbound/" + inbound.Type() + "[" + inbound.Tag() + "]" - done := adapter.LogElapsed(m.logger, "close ", name) - monitor.Start("close ", name) - err = E.Append(err, inbound.Close(), func(err error) error { - return E.Cause(err, "close ", name) - }) - monitor.Finish() - done() - } - return nil -} - func (m *Manager) Inbounds() []adapter.Inbound { m.access.Lock() defer m.access.Unlock() diff --git a/adapter/lifecycle.go b/adapter/lifecycle.go index 8fd8830b..7592d649 100644 --- a/adapter/lifecycle.go +++ b/adapter/lifecycle.go @@ -2,10 +2,12 @@ package adapter import ( "context" - "reflect" - "strings" + "slices" + "sync" "time" + "github.com/sagernet/sing-box/common/taskmonitor" + C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" @@ -48,8 +50,7 @@ func (s StartStage) String() string { } type Lifecycle interface { - Start(stage StartStage) error - Close() error + Start(stage StartStage, scope *Scope) error } type LifecycleService interface { @@ -57,55 +58,84 @@ type LifecycleService interface { Lifecycle } -func getServiceName(service any) string { - if named, ok := service.(interface { - Type() string - Tag() string - }); ok { - tag := named.Tag() - if tag != "" { - return named.Type() + "[" + tag + "]" - } - return named.Type() - } - t := reflect.TypeOf(service) - if t.Kind() == reflect.Ptr { - t = t.Elem() - } - return strings.ToLower(t.Name()) +type Scope struct { + ctx context.Context + cancel context.CancelFunc + logger log.ContextLogger + access sync.Mutex + cleanups []func() error + children map[Lifecycle]*Scope } -func Start(ctx context.Context, logger log.ContextLogger, stage StartStage, services ...Lifecycle) error { - for _, service := range services { - err := ctx.Err() - if err != nil { - return err - } - name := getServiceName(service) - done := LogElapsed(logger, stage, " ", name) - err = service.Start(stage) - done() - if err != nil { - return err - } +func NewScope(ctx context.Context, logger log.ContextLogger) *Scope { + ctx, cancel := context.WithCancel(ctx) + return &Scope{ + ctx: ctx, + cancel: cancel, + logger: logger, + children: make(map[Lifecycle]*Scope), + } +} + +func (s *Scope) Context() context.Context { + return s.ctx +} + +func (s *Scope) Add(cleanup func() error) { + s.access.Lock() + s.cleanups = append(s.cleanups, cleanup) + s.access.Unlock() +} + +func (s *Scope) Start(name string, component Lifecycle, stage StartStage) error { + s.access.Lock() + err := s.ctx.Err() + if err != nil { + s.access.Unlock() + return err + } + child, loaded := s.children[component] + if !loaded { + child = NewScope(s.ctx, s.logger) + s.children[component] = child + s.cleanups = append(s.cleanups, func() error { + done := LogElapsed(s.logger, "close ", name) + monitor := taskmonitor.New(s.logger, C.StopTimeout) + monitor.Start("close ", name) + closeErr := child.Close() + monitor.Finish() + done() + if closeErr != nil { + return E.Cause(closeErr, "close ", name) + } + return nil + }) + } + s.access.Unlock() + done := LogElapsed(s.logger, stage, " ", name) + monitor := taskmonitor.New(s.logger, C.StartTimeout) + monitor.Start(stage, " ", name) + err = component.Start(stage, child) + monitor.Finish() + done() + if err != nil { + return E.Cause(err, stage, " ", name) } return nil } -func StartNamed(ctx context.Context, logger log.ContextLogger, stage StartStage, services []LifecycleService) error { - for _, service := range services { - err := ctx.Err() - if err != nil { - return err - } - done := LogElapsed(logger, stage, " ", service.Name()) - err = service.Start(stage) - done() - if err != nil { - return E.Cause(err, stage.String(), " ", service.Name()) - } +func (s *Scope) Close() error { + s.access.Lock() + s.cancel() + cleanups := s.cleanups + s.cleanups = nil + s.children = nil + s.access.Unlock() + var err error + for _, cleanup := range slices.Backward(cleanups) { + err = E.Errors(err, cleanup()) } - return nil + return err } func LogElapsed(logger log.ContextLogger, description ...any) func() { diff --git a/adapter/outbound/manager.go b/adapter/outbound/manager.go index 5354bc9d..fd616363 100644 --- a/adapter/outbound/manager.go +++ b/adapter/outbound/manager.go @@ -2,39 +2,31 @@ package outbound import ( "context" - "io" "os" "strings" "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.OutboundManager = (*Manager)(nil) type Manager struct { - logger log.ContextLogger registry adapter.OutboundRegistry endpoint adapter.EndpointManager defaultTag string access sync.RWMutex - started bool - stage adapter.StartStage outbounds []adapter.Outbound outboundByTag map[string]adapter.Outbound defaultOutbound adapter.Outbound defaultOutboundFallback func() (adapter.Outbound, error) } -func NewManager(logger logger.ContextLogger, registry adapter.OutboundRegistry, endpoint adapter.EndpointManager, defaultTag string) *Manager { +func NewManager(registry adapter.OutboundRegistry, endpoint adapter.EndpointManager, defaultTag string) *Manager { return &Manager{ - logger: logger, registry: registry, endpoint: endpoint, defaultTag: defaultTag, @@ -46,13 +38,8 @@ func (m *Manager) Initialize(defaultOutboundFallback func() (adapter.Outbound, e m.defaultOutboundFallback = defaultOutboundFallback } -func (m *Manager) Start(stage adapter.StartStage) error { +func (m *Manager) Start(stage adapter.StartStage, scope *adapter.Scope) error { m.access.Lock() - if m.started && m.stage >= stage { - panic("already started") - } - m.started = true - m.stage = stage if stage == adapter.StartStateInitialize { if m.defaultTag != "" && m.defaultOutbound == nil { defaultEndpoint, loaded := m.endpoint.Get(m.defaultTag) @@ -76,26 +63,23 @@ func (m *Manager) Start(stage adapter.StartStage) error { outbounds := m.outbounds m.access.Unlock() if stage == adapter.StartStateStart { - return m.startOutbounds(append(outbounds, common.Map(m.endpoint.Endpoints(), func(it adapter.Endpoint) adapter.Outbound { return it })...)) + return m.startOutbounds(scope, append(outbounds, common.Map(m.endpoint.Endpoints(), func(it adapter.Endpoint) adapter.Outbound { return it })...)) } for _, outbound := range outbounds { - starter, isStarter := outbound.(adapter.Lifecycle) - if !isStarter { + lifecycle, isLifecycle := outbound.(adapter.Lifecycle) + if !isLifecycle { continue } name := "outbound/" + outbound.Type() + "[" + outbound.Tag() + "]" - done := adapter.LogElapsed(m.logger, stage, " ", name) - err := starter.Start(stage) - done() + err := scope.Start(name, lifecycle, stage) if err != nil { - return E.Cause(err, stage, " ", name) + return err } } return nil } -func (m *Manager) startOutbounds(outbounds []adapter.Outbound) error { - monitor := taskmonitor.New(m.logger, C.StartTimeout) +func (m *Manager) startOutbounds(scope *adapter.Scope, outbounds []adapter.Outbound) error { started := make(map[string]bool) for { canContinue := false @@ -113,27 +97,21 @@ func (m *Manager) startOutbounds(outbounds []adapter.Outbound) error { } started[outboundTag] = true canContinue = true + if endpoint, isEndpoint := outboundToStart.(adapter.Endpoint); isEndpoint { + err := m.endpoint.StartEndpoint(endpoint) + if err != nil { + return err + } + continue + } + lifecycle, isLifecycle := outboundToStart.(adapter.Lifecycle) + if !isLifecycle { + continue + } name := "outbound/" + outboundToStart.Type() + "[" + outboundTag + "]" - if starter, isStarter := outboundToStart.(adapter.Lifecycle); isStarter { - done := adapter.LogElapsed(m.logger, "start ", name) - monitor.Start("start ", name) - err := starter.Start(adapter.StartStateStart) - monitor.Finish() - done() - if err != nil { - return E.Cause(err, "start ", name) - } - } else if starter, isStarter := outboundToStart.(interface { - Start() error - }); isStarter { - done := adapter.LogElapsed(m.logger, "start ", name) - monitor.Start("start ", name) - err := starter.Start() - monitor.Finish() - done() - if err != nil { - return E.Cause(err, "start ", name) - } + err := scope.Start(name, lifecycle, adapter.StartStateStart) + if err != nil { + return err } } if len(started) == len(outbounds) { @@ -166,33 +144,6 @@ func (m *Manager) startOutbounds(outbounds []adapter.Outbound) error { 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 - outbounds := m.outbounds - m.outbounds = nil - m.access.Unlock() - var err error - for _, outbound := range outbounds { - if closer, isCloser := outbound.(io.Closer); isCloser { - name := "outbound/" + outbound.Type() + "[" + outbound.Tag() + "]" - done := adapter.LogElapsed(m.logger, "close ", name) - monitor.Start("close ", name) - err = E.Append(err, closer.Close(), func(err error) error { - return E.Cause(err, "close ", name) - }) - monitor.Finish() - done() - } - } - return nil -} - func (m *Manager) Outbounds() []adapter.Outbound { m.access.RLock() defer m.access.RUnlock() diff --git a/adapter/service/manager.go b/adapter/service/manager.go index 22d6740a..4b12665a 100644 --- a/adapter/service/manager.go +++ b/adapter/service/manager.go @@ -5,8 +5,6 @@ import ( "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" E "github.com/sagernet/sing/common/exceptions" ) @@ -14,68 +12,33 @@ import ( var _ adapter.ServiceManager = (*Manager)(nil) type Manager struct { - logger log.ContextLogger registry adapter.ServiceRegistry access sync.Mutex - started bool - stage adapter.StartStage services []adapter.Service serviceByTag map[string]adapter.Service } -func NewManager(logger log.ContextLogger, registry adapter.ServiceRegistry) *Manager { +func NewManager(registry adapter.ServiceRegistry) *Manager { return &Manager{ - logger: logger, registry: registry, serviceByTag: make(map[string]adapter.Service), } } -func (m *Manager) Start(stage adapter.StartStage) error { +func (m *Manager) Start(stage adapter.StartStage, scope *adapter.Scope) error { m.access.Lock() - if m.started && m.stage >= stage { - panic("already started") - } - m.started = true - m.stage = stage services := m.services m.access.Unlock() for _, service := range services { name := "service/" + service.Type() + "[" + service.Tag() + "]" - done := adapter.LogElapsed(m.logger, stage, " ", name) - err := service.Start(stage) - done() + err := scope.Start(name, service, stage) if err != nil { - return E.Cause(err, stage, " ", name) + return err } } return nil } -func (m *Manager) Close() error { - m.access.Lock() - defer m.access.Unlock() - if !m.started { - return nil - } - m.started = false - services := m.services - m.services = nil - monitor := taskmonitor.New(m.logger, C.StopTimeout) - var err error - for _, service := range services { - name := "service/" + service.Type() + "[" + service.Tag() + "]" - done := adapter.LogElapsed(m.logger, "close ", name) - monitor.Start("close ", name) - err = E.Append(err, service.Close(), func(err error) error { - return E.Cause(err, "close ", name) - }) - monitor.Finish() - done() - } - return nil -} - func (m *Manager) Services() []adapter.Service { m.access.Lock() defer m.access.Unlock() diff --git a/box.go b/box.go index ef3d497e..ba5eff38 100644 --- a/box.go +++ b/box.go @@ -2,11 +2,7 @@ package box import ( "context" - "fmt" "io" - "net/http" - "os" - "runtime/debug" "time" "github.com/sagernet/sing-box/adapter" @@ -44,10 +40,8 @@ import ( var _ adapter.SimpleLifecycle = (*Box)(nil) type Box struct { - ctx context.Context createdAt time.Time debugOptions option.DebugOptions - debugHTTPServer *http.Server logFactory log.Factory logger log.ContextLogger network *route.NetworkManager @@ -64,7 +58,7 @@ type Box struct { httpClientService adapter.LifecycleService internalService []adapter.LifecycleService ntpService *ntp.Service - done chan struct{} + scope *adapter.Scope } type Options struct { @@ -211,12 +205,12 @@ func New(options Options) (*Box, error) { service.MustRegister[adapter.NetworkNamespaceManager](ctx, netnsManager) internalServices = append(internalServices, netnsManager) dnsOptions := common.PtrValueOrDefault(options.DNS) - 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) - dnsTransportManager := dns.NewTransportManager(logFactory.NewLogger("dns/transport"), dnsTransportRegistry, outboundManager, dnsOptions.Final) - serviceManager := boxService.NewManager(logFactory.NewLogger("service"), serviceRegistry) - certificateProviderManager := boxCertificate.NewManager(logFactory.NewLogger("certificate-provider"), certificateProviderRegistry) + endpointManager := endpoint.NewManager(endpointRegistry) + inboundManager := inbound.NewManager(inboundRegistry, endpointManager) + outboundManager := outbound.NewManager(outboundRegistry, endpointManager, routeOptions.Final) + dnsTransportManager := dns.NewTransportManager(dnsTransportRegistry, outboundManager, dnsOptions.Final) + serviceManager := boxService.NewManager(serviceRegistry) + certificateProviderManager := boxCertificate.NewManager(certificateProviderRegistry) service.MustRegister[adapter.EndpointManager](ctx, endpointManager) service.MustRegister[adapter.InboundManager](ctx, inboundManager) service.MustRegister[adapter.OutboundManager](ctx, outboundManager) @@ -472,7 +466,6 @@ func New(options Options) (*Box, error) { timeService.TimeService = ntpService } return &Box{ - ctx: ctx, network: networkManager, endpoint: endpointManager, inbound: inboundManager, @@ -491,22 +484,13 @@ func New(options Options) (*Box, error) { logger: logFactory.Logger(), internalService: internalServices, ntpService: ntpService, - done: make(chan struct{}), + scope: adapter.NewScope(ctx, logFactory.Logger()), }, nil } func (s *Box) PreStart() error { err := s.preStart() if err != nil { - // TODO: remove catch error - defer func() { - v := recover() - if v != nil { - println(err.Error()) - debug.PrintStack() - panic("panic on early close: " + fmt.Sprint(v)) - } - }() s.Close() return err } @@ -517,15 +501,6 @@ func (s *Box) PreStart() error { func (s *Box) Start() error { err := s.start() if err != nil { - // TODO: remove catch error - defer func() { - v := recover() - if v != nil { - println(err.Error()) - debug.PrintStack() - println("panic on early start: " + fmt.Sprint(v)) - } - }() s.Close() return err } @@ -533,6 +508,31 @@ func (s *Box) Start() error { return nil } +type boxComponent struct { + name string + lifecycle adapter.Lifecycle +} + +func (s *Box) startComponents(stage adapter.StartStage, components ...boxComponent) error { + for _, component := range components { + err := s.scope.Start(component.name, component.lifecycle, stage) + if err != nil { + return err + } + } + return nil +} + +func (s *Box) startInternalServices(stage adapter.StartStage) error { + for _, lifecycleService := range s.internalService { + err := s.scope.Start(lifecycleService.Name(), lifecycleService, stage) + if err != nil { + return err + } + } + return nil +} + func (s *Box) preStart() error { monitor := taskmonitor.New(s.logger, C.StartTimeout) monitor.Start("start logger") @@ -541,28 +541,44 @@ func (s *Box) preStart() error { if err != nil { return E.Cause(err, "start logger") } + s.scope.Add(s.logFactory.Close) applyDebugOptions(s.debugOptions) - s.debugHTTPServer, err = startDebugHTTPServer(s.debugOptions) + debugHTTPServer, err := startDebugHTTPServer(s.debugOptions) if err != nil { return err } - err = adapter.StartNamed(s.ctx, s.logger, adapter.StartStateInitialize, s.internalService) // cache-file clash-api v2ray-api + if debugHTTPServer != nil { + s.scope.Add(debugHTTPServer.Close) + } + err = s.startInternalServices(adapter.StartStateInitialize) // cache-file clash-api v2ray-api if err != nil { return err } - err = adapter.Start(s.ctx, s.logger, adapter.StartStateInitialize, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.inbound, s.endpoint, s.service, s.certificateProvider) + err = s.startComponents(adapter.StartStateInitialize, + boxComponent{s.httpClientService.Name(), s.httpClientService}, + boxComponent{"network", s.network}, + boxComponent{"dns-transport", s.dnsTransport}, + boxComponent{"dns-router", s.dnsRouter}, + boxComponent{"connection", s.connection}, + boxComponent{"router", s.router}, + boxComponent{"outbound", s.outbound}, + boxComponent{"endpoint", s.endpoint}, + boxComponent{"certificate-provider", s.certificateProvider}, + boxComponent{"inbound", s.inbound}, + boxComponent{"service", s.service}, + ) if err != nil { return err } - err = adapter.Start(s.ctx, s.logger, adapter.StartStateStart, s.outbound, s.dnsTransport, s.network, s.connection) - if err != nil { - return err - } - err = adapter.StartNamed(s.ctx, s.logger, adapter.StartStateStart, []adapter.LifecycleService{s.httpClientService}) - if err != nil { - return err - } - err = adapter.Start(s.ctx, s.logger, adapter.StartStateStart, s.router, s.dnsRouter) + err = s.startComponents(adapter.StartStateStart, + boxComponent{"outbound", s.outbound}, + boxComponent{"dns-transport", s.dnsTransport}, + boxComponent{"network", s.network}, + boxComponent{"connection", s.connection}, + boxComponent{s.httpClientService.Name(), s.httpClientService}, + boxComponent{"router", s.router}, + boxComponent{"dns-router", s.dnsRouter}, + ) if err != nil { return err } @@ -574,7 +590,7 @@ func (s *Box) start() error { if err != nil { return err } - err = adapter.StartNamed(s.ctx, s.logger, adapter.StartStateStart, s.internalService) + err = s.startInternalServices(adapter.StartStateStart) if err != nil { return err } @@ -585,32 +601,52 @@ func (s *Box) start() error { if err != nil { return E.Cause(err, "start ntp service") } + s.scope.Add(s.ntpService.Close) } - err = adapter.Start(s.ctx, s.logger, adapter.StartStateStart, s.endpoint) + err = s.startComponents(adapter.StartStateStart, + boxComponent{"endpoint", s.endpoint}, + boxComponent{"certificate-provider", s.certificateProvider}, + boxComponent{"inbound", s.inbound}, + boxComponent{"service", s.service}, + ) if err != nil { return err } - err = adapter.Start(s.ctx, s.logger, adapter.StartStateStart, s.certificateProvider) + err = s.startComponents(adapter.StartStatePostStart, + boxComponent{"outbound", s.outbound}, + boxComponent{"network", s.network}, + boxComponent{"dns-transport", s.dnsTransport}, + boxComponent{"dns-router", s.dnsRouter}, + boxComponent{"connection", s.connection}, + boxComponent{"router", s.router}, + boxComponent{"endpoint", s.endpoint}, + boxComponent{"certificate-provider", s.certificateProvider}, + boxComponent{"inbound", s.inbound}, + boxComponent{"service", s.service}, + ) if err != nil { return err } - err = adapter.Start(s.ctx, s.logger, adapter.StartStateStart, s.inbound, s.service) + err = s.startInternalServices(adapter.StartStatePostStart) if err != nil { return err } - err = adapter.Start(s.ctx, s.logger, adapter.StartStatePostStart, s.outbound, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.endpoint, s.certificateProvider, s.inbound, s.service) + err = s.startComponents(adapter.StartStateStarted, + boxComponent{"network", s.network}, + boxComponent{"dns-transport", s.dnsTransport}, + boxComponent{"dns-router", s.dnsRouter}, + boxComponent{"connection", s.connection}, + boxComponent{"router", s.router}, + boxComponent{"outbound", s.outbound}, + boxComponent{"endpoint", s.endpoint}, + boxComponent{"certificate-provider", s.certificateProvider}, + boxComponent{"inbound", s.inbound}, + boxComponent{"service", s.service}, + ) if err != nil { return err } - err = adapter.StartNamed(s.ctx, s.logger, adapter.StartStatePostStart, s.internalService) - if err != nil { - return err - } - err = adapter.Start(s.ctx, s.logger, adapter.StartStateStarted, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.endpoint, s.certificateProvider, s.inbound, s.service) - if err != nil { - return err - } - err = adapter.StartNamed(s.ctx, s.logger, adapter.StartStateStarted, s.internalService) + err = s.startInternalServices(adapter.StartStateStarted) if err != nil { return err } @@ -618,68 +654,7 @@ func (s *Box) start() error { } func (s *Box) Close() error { - select { - case <-s.done: - return os.ErrClosed - default: - close(s.done) - } - var err error - if s.debugHTTPServer != nil { - err = E.Append(err, s.debugHTTPServer.Close(), func(err error) error { - return E.Cause(err, "close debug HTTP server") - }) - s.debugHTTPServer = nil - } - for _, closeItem := range []struct { - name string - service adapter.Lifecycle - }{ - {"service", s.service}, - {"inbound", s.inbound}, - {"certificate-provider", s.certificateProvider}, - {"endpoint", s.endpoint}, - {"outbound", s.outbound}, - {"router", s.router}, - {"connection", s.connection}, - {"dns-router", s.dnsRouter}, - {"dns-transport", s.dnsTransport}, - {"network", s.network}, - } { - done := adapter.LogElapsed(s.logger, "close ", closeItem.name) - err = E.Append(err, closeItem.service.Close(), func(err error) error { - return E.Cause(err, "close ", closeItem.name) - }) - done() - } - if s.httpClientService != nil { - s.logger.Trace("close ", s.httpClientService.Name()) - startTime := time.Now() - err = E.Append(err, s.httpClientService.Close(), func(err error) error { - return E.Cause(err, "close ", s.httpClientService.Name()) - }) - s.logger.Trace("close ", s.httpClientService.Name(), " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") - } - for _, lifecycleService := range s.internalService { - done := adapter.LogElapsed(s.logger, "close ", lifecycleService.Name()) - err = E.Append(err, lifecycleService.Close(), func(err error) error { - return E.Cause(err, "close ", lifecycleService.Name()) - }) - done() - } - if s.ntpService != nil { - done := adapter.LogElapsed(s.logger, "close ntp service") - err = E.Append(err, s.ntpService.Close(), func(err error) error { - return E.Cause(err, "close ntp service") - }) - done() - } - done := adapter.LogElapsed(s.logger, "close logger") - err = E.Append(err, s.logFactory.Close(), func(err error) error { - return E.Cause(err, "close logger") - }) - done() - return err + return s.scope.Close() } func (s *Box) Network() adapter.NetworkManager { diff --git a/common/certificate/store.go b/common/certificate/store.go index 34871a6b..326e804a 100644 --- a/common/certificate/store.go +++ b/common/certificate/store.go @@ -31,6 +31,7 @@ type Store struct { certificatePaths []string certificateDirectoryPaths []string watcher *fswatch.Watcher + anchorPEM []byte //nolint:unused // populated only on darwin && cgo via the storePlatform embed. platform storePlatform } @@ -92,10 +93,12 @@ func NewStore(ctx context.Context, logger logger.Logger, options option.Certific } store.watcher = watcher } - err := store.update() + currentPool, anchorPEM, err := store.load() if err != nil { return nil, E.Cause(err, "initializing certificate store") } + store.currentPool = currentPool + store.anchorPEM = anchorPEM return store, nil } @@ -103,31 +106,28 @@ func (s *Store) Name() string { return "certificate" } -func (s *Store) Start(stage adapter.StartStage) error { - if stage != adapter.StartStateStart { - return nil - } - if s.watcher != nil { - return s.watcher.Start() +func (s *Store) Start(stage adapter.StartStage, scope *adapter.Scope) error { + switch stage { + case adapter.StartStateInitialize: + s.access.Lock() + err := s.updatePlatformLocked(s.anchorPEM) + s.access.Unlock() + if err != nil { + return err + } + scope.Add(s.closePlatform) + case adapter.StartStateStart: + if s.watcher != nil { + err := s.watcher.Start() + if err != nil { + return err + } + scope.Add(s.watcher.Close) + } } return nil } -func (s *Store) Close() error { - watcher := s.watcher - s.watcher = nil - - var closeErr error - if watcher != nil { - closeErr = watcher.Close() - } - platformErr := s.closePlatform() - if platformErr != nil { - closeErr = platformErr - } - return closeErr -} - func (s *Store) Pool() *x509.CertPool { s.access.RLock() defer s.access.RUnlock() @@ -143,38 +143,50 @@ func (s *Store) ExclusiveAnchors() bool { } func (s *Store) update() error { - currentPool, err := s.newBasePool() + currentPool, anchorPEM, err := s.load() if err != nil { return err } + s.access.Lock() + defer s.access.Unlock() + s.currentPool = currentPool + s.anchorPEM = anchorPEM + return s.updatePlatformLocked(anchorPEM) +} + +func (s *Store) load() (*x509.CertPool, []byte, error) { + currentPool, err := s.newBasePool() + if err != nil { + return nil, nil, err + } pemBuffer := new(bytes.Buffer) switch s.storeType { case C.CertificateStoreMozilla: pemContent := mozillaIncludedPEM() if !currentPool.AppendCertsFromPEM([]byte(pemContent)) { - return E.New("invalid Mozilla included certificate PEM") + return nil, nil, E.New("invalid Mozilla included certificate PEM") } appendPEMBlock(pemBuffer, string(pemContent)) case C.CertificateStoreChrome: pemContent := chromeIncludedPEM() if !currentPool.AppendCertsFromPEM([]byte(pemContent)) { - return E.New("invalid Chrome included certificate PEM") + return nil, nil, E.New("invalid Chrome included certificate PEM") } appendPEMBlock(pemBuffer, string(pemContent)) } if s.certificate != "" { if !currentPool.AppendCertsFromPEM([]byte(s.certificate)) { - return E.New("invalid certificate PEM strings") + return nil, nil, E.New("invalid certificate PEM strings") } appendPEMBlock(pemBuffer, s.certificate) } for _, path := range s.certificatePaths { pemContent, err := filemanager.ReadFile(s.ctx, path) if err != nil { - return err + return nil, nil, err } if !currentPool.AppendCertsFromPEM(pemContent) { - return E.New("invalid certificate PEM file: ", path) + return nil, nil, E.New("invalid certificate PEM file: ", path) } appendPEMBlock(pemBuffer, string(pemContent)) } @@ -195,12 +207,9 @@ func (s *Store) update() error { } } if firstErr != nil { - return firstErr + return nil, nil, firstErr } - s.access.Lock() - defer s.access.Unlock() - s.currentPool = currentPool - return s.updatePlatformLocked(pemBuffer.Bytes()) + return currentPool, pemBuffer.Bytes(), nil } func appendPEMBlock(buffer *bytes.Buffer, block string) { diff --git a/common/httpclient/manager.go b/common/httpclient/manager.go index 45e05f21..820cbeab 100644 --- a/common/httpclient/manager.go +++ b/common/httpclient/manager.go @@ -61,7 +61,7 @@ func (m *Manager) Name() string { return "http-client" } -func (m *Manager) Start(stage adapter.StartStage) error { +func (m *Manager) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } @@ -72,6 +72,7 @@ func (m *Manager) Start(stage adapter.StartStage) error { } m.defaultTransport = sharedTransport } + scope.Add(m.close) return nil } @@ -163,12 +164,9 @@ func (m *Manager) ResetNetwork() { } } -func (m *Manager) Close() error { +func (m *Manager) close() error { m.access.Lock() defer m.access.Unlock() - if m.managedTransports == nil { - return nil - } var err error for _, transport := range m.managedTransports { err = E.Append(err, transport.close(), func(err error) error { diff --git a/common/listener/listener.go b/common/listener/listener.go index 7a58015c..2cbbbab5 100644 --- a/common/listener/listener.go +++ b/common/listener/listener.go @@ -89,7 +89,7 @@ func (l *Listener) Start() error { if common.Contains(l.network, N.NetworkUDP) { _, err := l.ListenUDP() if err != nil { - return err + return E.Errors(err, l.Close()) } l.packetOutboundClosed = make(chan struct{}) l.packetOutbound = make(chan *N.PacketBuffer, 64) @@ -109,11 +109,11 @@ func (l *Listener) Start() error { } systemProxy, err := settings.NewSystemProxy(l.ctx, M.ParseSocksaddrHostPort(listenAddrString, listenPort), l.systemProxySOCKS, nil) if err != nil { - return E.Cause(err, "initialize system proxy") + return E.Errors(E.Cause(err, "initialize system proxy"), l.Close()) } err = systemProxy.Enable() if err != nil { - return E.Errors(E.Cause(err, "set system proxy"), systemProxy.Close()) + return E.Errors(E.Cause(err, "set system proxy"), systemProxy.Close(), l.Close()) } l.systemProxy = systemProxy } diff --git a/common/netns/manager_linux.go b/common/netns/manager_linux.go index f53279c3..a4b906bf 100644 --- a/common/netns/manager_linux.go +++ b/common/netns/manager_linux.go @@ -23,7 +23,6 @@ type Manager struct { namespaces []option.NetworkNamespace holderArgs []string paths map[string]string - holders []*holder } type holder struct { @@ -64,7 +63,7 @@ func (m *Manager) Name() string { return "netns" } -func (m *Manager) Start(stage adapter.StartStage) error { +func (m *Manager) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateInitialize { return nil } @@ -80,7 +79,7 @@ func (m *Manager) Start(stage adapter.StartStage) error { return E.Cause(err, "network namespace[", namespace.Tag, "]") } case C.NetNsTypeUnshare: - err := m.startNamespace(namespace) + err := m.startNamespace(namespace, scope) if err != nil { return E.Cause(err, "network namespace[", namespace.Tag, "]") } @@ -89,7 +88,7 @@ func (m *Manager) Start(stage adapter.StartStage) error { return nil } -func (m *Manager) startNamespace(namespace option.NetworkNamespace) error { +func (m *Manager) startNamespace(namespace option.NetworkNamespace, scope *adapter.Scope) error { if len(m.holderArgs) == 0 { return E.New("unshare network namespace is only supported in `sing-box run`") } @@ -97,7 +96,13 @@ func (m *Manager) startNamespace(namespace option.NetworkNamespace) error { if err != nil { return err } - m.holders = append(m.holders, created) + scope.Add(func() error { + created.pipeWriter.Close() + if created.pidFile != "" { + os.Remove(created.pidFile) + } + return nil + }) pid := created.command.Process.Pid if created.pidFile != "" { err = os.WriteFile(created.pidFile, []byte(strconv.Itoa(pid)+"\n"), 0o644) @@ -115,17 +120,6 @@ func (m *Manager) startNamespace(namespace option.NetworkNamespace) error { return nil } -func (m *Manager) Close() error { - for _, created := range m.holders { - created.pipeWriter.Close() - if created.pidFile != "" { - os.Remove(created.pidFile) - } - } - m.holders = nil - return nil -} - func (m *Manager) ResolvePath(nameOrPath string) string { path, loaded := m.paths[nameOrPath] if loaded && path != "" { diff --git a/common/netns/manager_stub.go b/common/netns/manager_stub.go index 0bcd255a..23da25b1 100644 --- a/common/netns/manager_stub.go +++ b/common/netns/manager_stub.go @@ -22,11 +22,7 @@ func (m *Manager) Name() string { return "netns" } -func (m *Manager) Start(stage adapter.StartStage) error { - return nil -} - -func (m *Manager) Close() error { +func (m *Manager) Start(stage adapter.StartStage, scope *adapter.Scope) error { return nil } diff --git a/common/netns/manager_test.go b/common/netns/manager_test.go index 59b7ae11..6040ba2c 100644 --- a/common/netns/manager_test.go +++ b/common/netns/manager_test.go @@ -4,6 +4,7 @@ package netns import ( "bufio" + "context" "os" "strconv" "strings" @@ -12,6 +13,7 @@ import ( "github.com/sagernet/sing-box/adapter" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" F "github.com/sagernet/sing/common/format" "github.com/sagernet/sing/common/logger" @@ -39,11 +41,11 @@ func TestUnshareNamespace(t *testing.T) { if err != nil { t.Fatal(err) } - err = manager.Start(adapter.StartStateInitialize) + scope := adapter.NewScope(context.Background(), log.NewNOPFactory().Logger()) + err = manager.Start(adapter.StartStateInitialize, scope) if err != nil { t.Fatal(err) } - defer manager.Close() pipeReader.SetReadDeadline(time.Now().Add(10 * time.Second)) pidLine, err := bufio.NewReader(pipeReader).ReadString('\n') @@ -71,7 +73,7 @@ func TestUnshareNamespace(t *testing.T) { t.Fatal("holder is in the current netns") } - err = manager.Close() + err = scope.Close() if err != nil { t.Fatal(err) } diff --git a/common/tls/acme.go b/common/tls/acme.go index c736d08f..0bf554f4 100644 --- a/common/tls/acme.go +++ b/common/tls/acme.go @@ -49,9 +49,14 @@ func (w *acmeWrapper) Start() error { Logger: w.zapLogger, }) config = certmagic.New(cache, *config) + err := config.ManageSync(w.ctx, w.domain) + if err != nil { + cache.Stop() + return err + } w.cfg = config w.cache = cache - return w.cfg.ManageSync(w.ctx, w.domain) + return nil } func (w *acmeWrapper) Close() error { diff --git a/common/tls/std_server.go b/common/tls/std_server.go index d611f72a..171cc4f2 100644 --- a/common/tls/std_server.go +++ b/common/tls/std_server.go @@ -57,21 +57,26 @@ func (p *sharedCertificateProvider) GetACMENextProtos() []string { } type inlineCertificateProvider struct { + ctx context.Context + logger log.ContextLogger provider adapter.CertificateProviderService + scope *adapter.Scope } func (p *inlineCertificateProvider) Start() error { + p.scope = adapter.NewScope(p.ctx, p.logger) + name := "certificate-provider/" + p.provider.Type() for _, stage := range adapter.ListStartStages { - err := p.provider.Start(stage) + err := p.scope.Start(name, p.provider, stage) if err != nil { - return err + return E.Errors(err, p.scope.Close()) } } return nil } func (p *inlineCertificateProvider) Close() error { - return p.provider.Close() + return p.scope.Close() } func (p *inlineCertificateProvider) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { @@ -548,6 +553,8 @@ func newCertificateProvider(ctx context.Context, logger log.ContextLogger, optio return nil, E.Cause(err, "create inline certificate provider") } return &inlineCertificateProvider{ + ctx: ctx, + logger: logger, provider: provider, }, nil } diff --git a/common/trafficcontrol/manager.go b/common/trafficcontrol/manager.go index d5b6a2b5..19f6fd0a 100644 --- a/common/trafficcontrol/manager.go +++ b/common/trafficcontrol/manager.go @@ -45,7 +45,6 @@ type Manager struct { eventSubscriber *observable.Subscriber[ConnectionEvent] eventObserver *observable.Observer[ConnectionEvent] - cleaner *cleanup.Cleaner } func NewManager(outbound adapter.OutboundManager) *Manager { @@ -59,20 +58,15 @@ func (m *Manager) Name() string { return "traffic manager" } -func (m *Manager) Start(stage adapter.StartStage) error { +func (m *Manager) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage == adapter.StartStateInitialize { m.eventObserver = observable.NewObserver(m.eventSubscriber, 64) - m.cleaner = cleanup.Add(m.Clear) - } - return nil -} - -func (m *Manager) Close() error { - if m.cleaner != nil { - m.cleaner.Close() - } - if m.eventObserver != nil { - return m.eventObserver.Close() + scope.Add(m.eventObserver.Close) + cleaner := cleanup.Add(m.Clear) + scope.Add(func() error { + cleaner.Close() + return nil + }) } return nil } diff --git a/dns/router.go b/dns/router.go index d5b07031..ec8ef4e1 100644 --- a/dns/router.go +++ b/dns/router.go @@ -129,10 +129,19 @@ func (r *Router) Initialize(rules []option.DNSRule) error { return nil } -func (r *Router) Start(stage adapter.StartStage) error { +func (r *Router) Start(stage adapter.StartStage, scope *adapter.Scope) error { monitor := taskmonitor.New(r.logger, C.StartTimeout) switch stage { case adapter.StartStateStart: + scope.Add(func() error { + r.rulesAccess.Lock() + r.closing = true + runtimeRules := r.rules + r.rules = nil + r.rulesAccess.Unlock() + closeRules(runtimeRules) + return nil + }) monitor.Start("initialize DNS client") r.client.Start() monitor.Finish() @@ -163,20 +172,6 @@ func (r *Router) Start(stage adapter.StartStage) error { return nil } -func (r *Router) Close() error { - r.rulesAccess.Lock() - if r.closing { - r.rulesAccess.Unlock() - return nil - } - r.closing = true - runtimeRules := r.rules - r.rules = nil - r.rulesAccess.Unlock() - closeRules(runtimeRules) - return nil -} - func (r *Router) buildRules(startRules bool) ([]adapter.DNSRule, bool, dnsRuleModeFlags, error) { for i, ruleOptions := range r.rawRules { err := R.ValidateNoNestedDNSRuleActions(ruleOptions) diff --git a/dns/router_race_test.go b/dns/router_race_test.go index 910f9e91..eabb5480 100644 --- a/dns/router_race_test.go +++ b/dns/router_race_test.go @@ -31,11 +31,7 @@ type fakeDNSTransport struct { firstQueried time.Time } -func (t *fakeDNSTransport) Start(stage adapter.StartStage) error { - return nil -} - -func (t *fakeDNSTransport) Close() error { +func (t *fakeDNSTransport) Start(stage adapter.StartStage, scope *adapter.Scope) error { return nil } @@ -90,11 +86,7 @@ type fakeDNSTransportManager struct { defaultTransport adapter.DNSTransport } -func (m *fakeDNSTransportManager) Start(stage adapter.StartStage) error { - return nil -} - -func (m *fakeDNSTransportManager) Close() error { +func (m *fakeDNSTransportManager) Start(stage adapter.StartStage, scope *adapter.Scope) error { return nil } diff --git a/dns/transport/dhcp/dhcp.go b/dns/transport/dhcp/dhcp.go index 0f0aa102..329dc537 100644 --- a/dns/transport/dhcp/dhcp.go +++ b/dns/transport/dhcp/dhcp.go @@ -18,7 +18,6 @@ import ( "github.com/sagernet/sing-box/dns/transport" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" - "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/buf" "github.com/sagernet/sing/common/control" @@ -27,7 +26,6 @@ import ( M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/common/task" - "github.com/sagernet/sing/common/x/list" "github.com/sagernet/sing/service" "github.com/insomniacslk/dhcp/dhcpv4" @@ -54,7 +52,6 @@ type Transport struct { networkManager adapter.NetworkManager platformInterface adapter.PlatformInterface interfaceName string - interfaceCallback *list.Element[tun.DefaultInterfaceUpdateCallback] updateAccess sync.Mutex updateCancel context.CancelFunc refreshAccess sync.Mutex @@ -70,6 +67,7 @@ type transportState struct { search []string servers []M.Socksaddr serverTransports []adapter.DNSTransport + serverScope *adapter.Scope } func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.DHCPDNSServerOptions) (adapter.DNSTransport, error) { @@ -104,19 +102,29 @@ func NewRawTransport(transportAdapter dns.TransportAdapter, ctx context.Context, } } -func (t *Transport) Start(stage adapter.StartStage) error { +func (t *Transport) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } + scope.Add(func() error { + t.Reset() + return nil + }) if t.interfaceName == "" { interfaceMonitor := t.networkManager.InterfaceMonitor() if interfaceMonitor == nil { return E.New("missing monitor for auto DHCP, set route.auto_detect_interface") } - t.interfaceCallback = interfaceMonitor.RegisterCallback(t.interfaceUpdated) + interfaceCallback := interfaceMonitor.RegisterCallback(func(defaultInterface *control.Interface, flags int) { + t.interfaceUpdated(scope.Context()) + }) + scope.Add(func() error { + interfaceMonitor.UnregisterCallback(interfaceCallback) + return nil + }) } go func() { - err := t.fetch() + err := t.fetch(scope.Context()) if err != nil { if errors.Is(err, errInterfaceIsCellular) && t.optional { t.logger.Debug(E.Cause(errInterfaceIsCellular, "dhcp: fetch DNS servers")) @@ -128,32 +136,12 @@ func (t *Transport) Start(stage adapter.StartStage) error { return nil } -func (t *Transport) Close() error { - if t.interfaceCallback != nil { - t.networkManager.InterfaceMonitor().UnregisterCallback(t.interfaceCallback) - } - t.updateAccess.Lock() - updateCancel := t.updateCancel - t.updateCancel = nil - t.updateAccess.Unlock() - if updateCancel != nil { - updateCancel() - } - t.refreshAccess.Lock() - defer t.refreshAccess.Unlock() - state := t.savedState.Swap(nil) - if state != nil { - closeServerTransports(state.serverTransports) - } - return nil -} - func (t *Transport) Reset() { t.refreshAccess.Lock() defer t.refreshAccess.Unlock() state := t.savedState.Swap(nil) - if state != nil { - closeServerTransports(state.serverTransports) + if state != nil && state.serverScope != nil { + state.serverScope.Close() } } @@ -169,12 +157,6 @@ func (t *Transport) Environment() []string { return append(environment, state.search...) } -func closeServerTransports(serverTransports []adapter.DNSTransport) { - for _, serverTransport := range serverTransports { - serverTransport.Close() - } -} - func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { done := make(chan struct{}) var ( @@ -211,7 +193,7 @@ func (t *Transport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callba } func (t *Transport) exchangeCold(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { - err := t.fetch() + err := t.fetch(t.ctx) if err != nil { callback(nil, E.Cause(err, "dhcp: fetch DNS servers")) return @@ -235,7 +217,7 @@ func (t *Transport) Fetch() []M.Socksaddr { return state.servers } -func (t *Transport) fetch() error { +func (t *Transport) fetch(ctx context.Context) error { state := t.savedState.Load() if state != nil { if state.lastError != nil { @@ -256,7 +238,7 @@ func (t *Transport) fetch() error { return nil } } - return t.updateServersLocked(t.ctx) + return t.updateServersLocked(ctx) } func (t *Transport) startRefresh() { @@ -342,12 +324,13 @@ func (t *Transport) storeFailureLocked(err error) { newState.search = previousState.search newState.servers = previousState.servers newState.serverTransports = previousState.serverTransports + newState.serverScope = previousState.serverScope } t.savedState.Store(newState) } -func (t *Transport) interfaceUpdated(defaultInterface *control.Interface, flags int) { - updateContext, updateCancel := context.WithCancel(t.ctx) +func (t *Transport) interfaceUpdated(ctx context.Context) { + updateContext, updateCancel := context.WithCancel(ctx) t.updateAccess.Lock() previousCancel := t.updateCancel t.updateCancel = updateCancel @@ -483,25 +466,25 @@ func (t *Transport) recreateServersLocked(iface *control.Interface, dhcpPacket * } if serversUnchanged && previousState.serverTransports != nil { newState.serverTransports = previousState.serverTransports + newState.serverScope = previousState.serverScope t.savedState.Store(newState) return nil } + serverScope := adapter.NewScope(t.ctx, t.logger) serverTransports := make([]adapter.DNSTransport, 0, len(newState.servers)) for _, serverAddr := range newState.servers { serverTransport := transport.NewUDPRaw(t.logger, dns.NewTransportAdapter(C.DNSTypeUDP, "", nil), t.dialer, serverAddr) - err := serverTransport.Start(adapter.StartStateStart) + err := serverTransport.Start(adapter.StartStateStart, serverScope) if err != nil { - for _, startedTransport := range serverTransports { - startedTransport.Close() - } - return E.Cause(err, "initialize transport for ", serverAddr) + return E.Errors(E.Cause(err, "initialize transport for ", serverAddr), serverScope.Close()) } serverTransports = append(serverTransports, serverTransport) } newState.serverTransports = serverTransports + newState.serverScope = serverScope t.savedState.Store(newState) - if previousState != nil { - closeServerTransports(previousState.serverTransports) + if previousState != nil && previousState.serverScope != nil { + previousState.serverScope.Close() } return nil } diff --git a/dns/transport/fakeip/fakeip.go b/dns/transport/fakeip/fakeip.go index 025ddecf..03daeffb 100644 --- a/dns/transport/fakeip/fakeip.go +++ b/dns/transport/fakeip/fakeip.go @@ -45,15 +45,16 @@ func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, opt }, nil } -func (t *Transport) Start(stage adapter.StartStage) error { +func (t *Transport) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } - return t.store.Start() -} - -func (t *Transport) Close() error { - return t.store.Close() + err := t.store.Start() + if err != nil { + return err + } + scope.Add(t.store.Close) + return nil } func (t *Transport) Reset() { diff --git a/dns/transport/hosts/hosts.go b/dns/transport/hosts/hosts.go index 2ba47063..d0f8fbff 100644 --- a/dns/transport/hosts/hosts.go +++ b/dns/transport/hosts/hosts.go @@ -58,11 +58,7 @@ func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, opt }, nil } -func (t *Transport) Start(stage adapter.StartStage) error { - return nil -} - -func (t *Transport) Close() error { +func (t *Transport) Start(stage adapter.StartStage, scope *adapter.Scope) error { return nil } diff --git a/dns/transport/https.go b/dns/transport/https.go index 582677ec..4d5d3fb2 100644 --- a/dns/transport/https.go +++ b/dns/transport/https.go @@ -149,18 +149,17 @@ func NewHTTPSRaw( return transport } -func (t *HTTPSTransport) Start(stage adapter.StartStage) error { +func (t *HTTPSTransport) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } + scope.Add(func() error { + t.Reset() + return nil + }) return dialer.InitializeDetour(t.dialer) } -func (t *HTTPSTransport) Close() error { - t.Reset() - return nil -} - func (t *HTTPSTransport) Reset() { t.transportAccess.Lock() defer t.transportAccess.Unlock() diff --git a/dns/transport/local/local.go b/dns/transport/local/local.go index b7bf8347..9c24768b 100644 --- a/dns/transport/local/local.go +++ b/dns/transport/local/local.go @@ -12,7 +12,6 @@ import ( "github.com/sagernet/sing-box/dns/transport/mdns" "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" @@ -39,6 +38,7 @@ type Transport struct { preferGo bool resolved ResolvedResolver mdnsTransport adapter.DNSTransport + mdnsScope *adapter.Scope configSource *systemconfig.Source system systemResolver serverSet atomic.Pointer[localServerSet] @@ -65,13 +65,15 @@ func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, opt }, nil } -func (t *Transport) Start(stage adapter.StartStage) error { +func (t *Transport) Start(stage adapter.StartStage, scope *adapter.Scope) error { t.preferredResolver.Start(stage) switch stage { case adapter.StartStateInitialize: + scope.Add(t.configSource.Close) if !t.preferGo && isSystemdResolvedManaged() { resolvedResolver, err := NewResolvedResolver(t.ctx, t.logger) if err == nil { + scope.Add(resolvedResolver.Close) err = resolvedResolver.Start() if err == nil { t.resolved = resolvedResolver @@ -81,13 +83,26 @@ func (t *Transport) Start(stage adapter.StartStage) error { } } case adapter.StartStateStart: + scope.Add(func() error { + t.system.close() + return nil + }) + scope.Add(func() error { + serverSet := t.serverSet.Swap(nil) + if serverSet != nil { + return serverSet.serverScope.Close() + } + return nil + }) if !C.IsDarwin { t.mdnsTransport = mdns.NewRawTransport(t.TransportAdapter, t.ctx, t.logger) + t.mdnsScope = adapter.NewScope(t.ctx, t.logger) + scope.Add(t.mdnsScope.Close) } fallthrough default: if t.mdnsTransport != nil { - err := t.mdnsTransport.Start(stage) + err := t.mdnsTransport.Start(stage, t.mdnsScope) if err != nil { return err } @@ -96,15 +111,6 @@ func (t *Transport) Start(stage adapter.StartStage) error { return nil } -func (t *Transport) Close() error { - serverSet := t.serverSet.Swap(nil) - if serverSet != nil { - serverSet.Close() - } - t.system.close() - return common.Close(t.resolved, t.mdnsTransport, t.configSource) -} - func (t *Transport) Reset() { serverSet := t.serverSet.Load() if serverSet != nil { diff --git a/dns/transport/local/local_resolved_linux.go b/dns/transport/local/local_resolved_linux.go index 6a7eb3cb..0272854d 100644 --- a/dns/transport/local/local_resolved_linux.go +++ b/dns/transport/local/local_resolved_linux.go @@ -66,12 +66,12 @@ type DBusResolvedResolver struct { updateCancel context.CancelFunc updateRunAccess sync.Mutex closed bool - closeOnce sync.Once } type resolvedServerSet struct { - scopes []*resolvedScope - signature []string + scopes []*resolvedScope + signature []string + serverScope *adapter.Scope } // Match levels of dns_scope_good_domain() in systemd-resolved: a routing or search @@ -198,32 +198,26 @@ func (t *DBusResolvedResolver) Start() error { } func (t *DBusResolvedResolver) Close() error { + t.updateAccess.Lock() + updateCancel := t.updateCancel + t.updateCancel = nil + t.updateAccess.Unlock() + if updateCancel != nil { + updateCancel() + } + t.updateRunAccess.Lock() + t.closed = true + serverSet := t.savedServerSet.Swap(nil) + t.updateRunAccess.Unlock() var closeErr error - t.closeOnce.Do(func() { - t.updateAccess.Lock() - updateCancel := t.updateCancel - t.updateCancel = nil - t.updateAccess.Unlock() - if updateCancel != nil { - updateCancel() - } - t.updateRunAccess.Lock() - t.closed = true - serverSet := t.savedServerSet.Swap(nil) - t.updateRunAccess.Unlock() - if serverSet != nil { - closeErr = serverSet.Close() - } - if t.interfaceCallback != nil { - t.interfaceMonitor.UnregisterCallback(t.interfaceCallback) - } - if t.networkCallback != nil { - t.networkMonitor.UnregisterCallback(t.networkCallback) - } - if t.systemBus != nil { - _ = t.systemBus.Close() - } - }) + if serverSet != nil { + closeErr = serverSet.serverScope.Close() + } + t.interfaceMonitor.UnregisterCallback(t.interfaceCallback) + if t.networkCallback != nil { + t.networkMonitor.UnregisterCallback(t.networkCallback) + } + _ = t.systemBus.Close() return closeErr } @@ -385,21 +379,6 @@ func (s *resolvedScope) match(name string) int { return resolvedScopeMaybe } -func (s *resolvedServerSet) Close() error { - return E.Errors(common.Map(s.scopes, (*resolvedScope).Close)...) -} - -func (s *resolvedScope) Close() error { - var errors []error - for _, server := range s.servers { - errors = append(errors, server.primaryTransport.Close()) - if server.fallbackTransport != nil { - errors = append(errors, server.fallbackTransport.Close()) - } - } - return E.Errors(errors...) -} - func (t *DBusResolvedResolver) loopUpdateStatus() { signalChan := make(chan *dbus.Signal, 1) t.systemBus.Signal(signalChan) @@ -457,7 +436,7 @@ func (t *DBusResolvedResolver) updateStatus(ctx context.Context) error { serverSet, err := t.checkResolved(ctx) if t.closed || ctx.Err() != nil { if serverSet != nil { - _ = serverSet.Close() + _ = serverSet.serverScope.Close() } if t.closed { return os.ErrClosed @@ -466,7 +445,7 @@ func (t *DBusResolvedResolver) updateStatus(ctx context.Context) error { } oldServerSet := t.savedServerSet.Swap(serverSet) if oldServerSet != nil { - _ = oldServerSet.Close() + _ = oldServerSet.serverScope.Close() } if err != nil { var dbusErr dbus.Error @@ -579,11 +558,13 @@ func (t *DBusResolvedResolver) checkResolved(ctx context.Context) (*resolvedServ if len(scopeSpecifications) == 0 { return nil, E.New("no DNS servers configured") } - serverSet := &resolvedServerSet{} + serverSet := &resolvedServerSet{ + serverScope: adapter.NewScope(t.ctx, t.logger), + } for _, scopeSpecification := range scopeSpecifications { - scope, createErr := t.createResolvedScope(scopeSpecification) + scope, createErr := t.createResolvedScope(serverSet.serverScope, scopeSpecification) if createErr != nil { - _ = serverSet.Close() + _ = serverSet.serverScope.Close() return nil, createErr } serverSet.scopes = append(serverSet.scopes, scope) @@ -630,7 +611,7 @@ func (t *DBusResolvedResolver) loadResolvedLinkScope(ctx context.Context, manage return linkScope, true, nil } -func (t *DBusResolvedResolver) createResolvedScope(scopeSpecification resolvedScopeSpecification) (*resolvedScope, error) { +func (t *DBusResolvedResolver) createResolvedScope(serverScope *adapter.Scope, scopeSpecification resolvedScopeSpecification) (*resolvedScope, error) { scope := &resolvedScope{ domains: scopeSpecification.domains, defaultRoute: scopeSpecification.defaultRoute, @@ -646,9 +627,8 @@ func (t *DBusResolvedResolver) createResolvedScope(scopeSpecification resolvedSc return nil, err } for _, serverSpecification := range scopeSpecification.servers { - server, createErr := t.createResolvedServer(serverDialer, scopeSpecification.dnsOverTLSMode, serverSpecification) + server, createErr := t.createResolvedServer(serverScope, serverDialer, scopeSpecification.dnsOverTLSMode, serverSpecification) if createErr != nil { - _ = scope.Close() return nil, createErr } scope.servers = append(scope.servers, server) @@ -656,9 +636,9 @@ func (t *DBusResolvedResolver) createResolvedScope(scopeSpecification resolvedSc return scope, nil } -func (t *DBusResolvedResolver) createResolvedServer(serverDialer N.Dialer, dnsOverTLSMode string, serverSpecification resolvedServerSpecification) (resolvedServer, error) { +func (t *DBusResolvedResolver) createResolvedServer(serverScope *adapter.Scope, serverDialer N.Dialer, dnsOverTLSMode string, serverSpecification resolvedServerSpecification) (resolvedServer, error) { if dnsOverTLSMode == "yes" { - primaryTransport, err := t.createResolvedTransport(serverDialer, serverSpecification, true) + primaryTransport, err := t.createResolvedTransport(serverScope, serverDialer, serverSpecification, true) if err != nil { return resolvedServer{}, err } @@ -667,13 +647,12 @@ func (t *DBusResolvedResolver) createResolvedServer(serverDialer N.Dialer, dnsOv }, nil } if dnsOverTLSMode == "opportunistic" { - primaryTransport, err := t.createResolvedTransport(serverDialer, serverSpecification, true) + primaryTransport, err := t.createResolvedTransport(serverScope, serverDialer, serverSpecification, true) if err != nil { return resolvedServer{}, err } - fallbackTransport, err := t.createResolvedTransport(serverDialer, serverSpecification, false) + fallbackTransport, err := t.createResolvedTransport(serverScope, serverDialer, serverSpecification, false) if err != nil { - _ = primaryTransport.Close() return resolvedServer{}, err } return resolvedServer{ @@ -681,7 +660,7 @@ func (t *DBusResolvedResolver) createResolvedServer(serverDialer N.Dialer, dnsOv fallbackTransport: fallbackTransport, }, nil } - primaryTransport, err := t.createResolvedTransport(serverDialer, serverSpecification, false) + primaryTransport, err := t.createResolvedTransport(serverScope, serverDialer, serverSpecification, false) if err != nil { return resolvedServer{}, err } @@ -690,7 +669,7 @@ func (t *DBusResolvedResolver) createResolvedServer(serverDialer N.Dialer, dnsOv }, nil } -func (t *DBusResolvedResolver) createResolvedTransport(serverDialer N.Dialer, serverSpecification resolvedServerSpecification, useTLS bool) (adapter.DNSTransport, error) { +func (t *DBusResolvedResolver) createResolvedTransport(serverScope *adapter.Scope, serverDialer N.Dialer, serverSpecification resolvedServerSpecification, useTLS bool) (adapter.DNSTransport, error) { serverAddress := M.SocksaddrFrom(serverSpecification.address, resolvedServerPort(serverSpecification.port, useTLS)) if useTLS { tlsAddress := serverSpecification.address @@ -709,17 +688,15 @@ func (t *DBusResolvedResolver) createResolvedTransport(serverDialer N.Dialer, se return nil, err } serverTransport := dnsTransport.NewTLSRaw(t.logger, dns.NewTransportAdapter(C.DNSTypeTLS, "", nil), serverDialer, serverAddress, tlsConfig) - err = serverTransport.Start(adapter.StartStateStart) + err = serverTransport.Start(adapter.StartStateStart, serverScope) if err != nil { - _ = serverTransport.Close() return nil, err } return serverTransport, nil } serverTransport := dnsTransport.NewUDPRaw(t.logger, dns.NewTransportAdapter(C.DNSTypeUDP, "", nil), serverDialer, serverAddress) - err := serverTransport.Start(adapter.StartStateStart) + err := serverTransport.Start(adapter.StartStateStart, serverScope) if err != nil { - _ = serverTransport.Close() return nil, err } return serverTransport, nil diff --git a/dns/transport/local/local_shared.go b/dns/transport/local/local_shared.go index b86f6d78..e0d5d0f3 100644 --- a/dns/transport/local/local_shared.go +++ b/dns/transport/local/local_shared.go @@ -14,14 +14,9 @@ import ( ) type localServerSet struct { - config *systemconfig.Config - transports []adapter.DNSTransport -} - -func (s *localServerSet) Close() { - for _, serverTransport := range s.transports { - serverTransport.Close() - } + config *systemconfig.Config + transports []adapter.DNSTransport + serverScope *adapter.Scope } func (t *Transport) serverSetFor(systemConfig *systemconfig.Config) (*localServerSet, error) { @@ -35,6 +30,7 @@ func (t *Transport) serverSetFor(systemConfig *systemconfig.Config) (*localServe if serverSet != nil && serverSet.config == systemConfig { return serverSet, nil } + serverScope := adapter.NewScope(t.ctx, t.logger) transports := make([]adapter.DNSTransport, 0, len(systemConfig.Servers)) for _, serverAddr := range systemConfig.Servers { var serverTransport adapter.DNSTransport @@ -43,22 +39,20 @@ func (t *Transport) serverSetFor(systemConfig *systemconfig.Config) (*localServe } else { serverTransport = transport.NewUDPRaw(t.logger, dns.NewTransportAdapter(C.DNSTypeUDP, "", nil), t.dialer, serverAddr) } - err := serverTransport.Start(adapter.StartStateStart) + err := serverTransport.Start(adapter.StartStateStart, serverScope) if err != nil { - for _, startedTransport := range transports { - startedTransport.Close() - } - return nil, E.Cause(err, "initialize transport for ", serverAddr) + return nil, E.Errors(E.Cause(err, "initialize transport for ", serverAddr), serverScope.Close()) } transports = append(transports, serverTransport) } newServerSet := &localServerSet{ - config: systemConfig, - transports: transports, + config: systemConfig, + transports: transports, + serverScope: serverScope, } oldServerSet := t.serverSet.Swap(newServerSet) if oldServerSet != nil { - oldServerSet.Close() + oldServerSet.serverScope.Close() } return newServerSet, nil } diff --git a/dns/transport/mdns/mdns.go b/dns/transport/mdns/mdns.go index 18542c55..7f7c30b3 100644 --- a/dns/transport/mdns/mdns.go +++ b/dns/transport/mdns/mdns.go @@ -93,13 +93,9 @@ func NewRawTransport(transportAdapter dns.TransportAdapter, ctx context.Context, } } -func (t *Transport) Start(stage adapter.StartStage) error { - return nil -} - -func (t *Transport) Close() error { - if t.configSource != nil { - return t.configSource.Close() +func (t *Transport) Start(stage adapter.StartStage, scope *adapter.Scope) error { + if stage == adapter.StartStateInitialize && t.configSource != nil { + scope.Add(t.configSource.Close) } return nil } diff --git a/dns/transport/multiplexer_test.go b/dns/transport/multiplexer_test.go index b5bd02b6..9ab3ab8b 100644 --- a/dns/transport/multiplexer_test.go +++ b/dns/transport/multiplexer_test.go @@ -9,9 +9,11 @@ import ( "testing" "time" + "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/dialer" C "github.com/sagernet/sing-box/constant" boxDNS "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" M "github.com/sagernet/sing/common/metadata" @@ -114,7 +116,16 @@ func newTestTCPTransport(t *testing.T, listener net.Listener) *TCPTransport { if err != nil { t.Fatal(err) } - return NewTCPRaw(boxDNS.NewTransportAdapter(C.DNSTypeTCP, "test", nil), transportDialer, M.SocksaddrFromNet(listener.Addr())) + transport := NewTCPRaw(boxDNS.NewTransportAdapter(C.DNSTypeTCP, "test", nil), transportDialer, M.SocksaddrFromNet(listener.Addr())) + scope := adapter.NewScope(context.Background(), log.NewNOPFactory().Logger()) + err = transport.Start(adapter.StartStateStart, scope) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + scope.Close() + }) + return transport } func testExchange(transport *TCPTransport, questionName string) error { @@ -155,7 +166,6 @@ func TestTCPTransportSingleQueryServer(t *testing.T) { }() transport := newTestTCPTransport(t, listener) - defer transport.Close() const queryCount = 8 results := make(chan error, queryCount) @@ -221,7 +231,6 @@ func TestTCPTransportProbeEnablesReuse(t *testing.T) { }() transport := newTestTCPTransport(t, listener) - defer transport.Close() deadline := time.Now().Add(3 * time.Second) for maxServedOnConn.Load() < 3 { @@ -284,7 +293,6 @@ func TestTCPTransportDemotesBrokenReuse(t *testing.T) { }() transport := newTestTCPTransport(t, listener) - defer transport.Close() deadline := time.Now().Add(3 * time.Second) for { @@ -364,7 +372,6 @@ func TestTCPTransportSilentPipelineServer(t *testing.T) { }() transport := newTestTCPTransport(t, listener) - defer transport.Close() const queryCount = 5 results := make(chan error, queryCount) diff --git a/dns/transport/quic/http3.go b/dns/transport/quic/http3.go index ca7d49d0..e50d47c0 100644 --- a/dns/transport/quic/http3.go +++ b/dns/transport/quic/http3.go @@ -137,19 +137,18 @@ func (t *HTTP3Transport) newTransport() *http3.Transport { } } -func (t *HTTP3Transport) Start(stage adapter.StartStage) error { +func (t *HTTP3Transport) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } + scope.Add(func() error { + t.transportAccess.Lock() + defer t.transportAccess.Unlock() + return t.transport.Close() + }) return dialer.InitializeDetour(t.dialer) } -func (t *HTTP3Transport) Close() error { - t.transportAccess.Lock() - defer t.transportAccess.Unlock() - return t.transport.Close() -} - func (t *HTTP3Transport) Reset() { t.transportAccess.Lock() defer t.transportAccess.Unlock() diff --git a/dns/transport/quic/quic.go b/dns/transport/quic/quic.go index 0b641bb3..bc2acaf0 100644 --- a/dns/transport/quic/quic.go +++ b/dns/transport/quic/quic.go @@ -79,17 +79,14 @@ func NewQUIC(ctx context.Context, logger log.ContextLogger, tag string, options }, nil } -func (t *Transport) Start(stage adapter.StartStage) error { +func (t *Transport) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } + scope.Add(t.connection.Close) return dialer.InitializeDetour(t.dialer) } -func (t *Transport) Close() error { - return t.connection.Close() -} - func (t *Transport) Reset() { t.connection.Reset() } diff --git a/dns/transport/tcp.go b/dns/transport/tcp.go index a4b72724..af83ad62 100644 --- a/dns/transport/tcp.go +++ b/dns/transport/tcp.go @@ -79,17 +79,14 @@ func NewTCPRaw(adapter dns.TransportAdapter, dialer N.Dialer, serverAddr M.Socks return t } -func (t *TCPTransport) Start(stage adapter.StartStage) error { +func (t *TCPTransport) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } + scope.Add(t.multiplexer.Close) return dialer.InitializeDetour(t.dialer) } -func (t *TCPTransport) Close() error { - return t.multiplexer.Close() -} - func (t *TCPTransport) Reset() { t.multiplexer.Reset() } diff --git a/dns/transport/tls.go b/dns/transport/tls.go index 36cd8dd3..ed70b7ef 100644 --- a/dns/transport/tls.go +++ b/dns/transport/tls.go @@ -85,17 +85,14 @@ func NewTLSRaw(logger logger.ContextLogger, adapter dns.TransportAdapter, dialer return t } -func (t *TLSTransport) Start(stage adapter.StartStage) error { +func (t *TLSTransport) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } + scope.Add(t.multiplexer.Close) return dialer.InitializeDetour(t.dialer) } -func (t *TLSTransport) Close() error { - return t.multiplexer.Close() -} - func (t *TLSTransport) Reset() { t.multiplexer.Reset() } diff --git a/dns/transport/udp.go b/dns/transport/udp.go index ea6deb16..a6bcd188 100644 --- a/dns/transport/udp.go +++ b/dns/transport/udp.go @@ -79,17 +79,14 @@ func NewUDPRaw(logger logger.ContextLogger, adapter dns.TransportAdapter, dialer return t } -func (t *UDPTransport) Start(stage adapter.StartStage) error { +func (t *UDPTransport) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } + scope.Add(t.multiplexer.Close) return dialer.InitializeDetour(t.dialer) } -func (t *UDPTransport) Close() error { - return t.multiplexer.Close() -} - func (t *UDPTransport) Reset() { t.multiplexer.Reset() } diff --git a/dns/transport_manager.go b/dns/transport_manager.go index 120357c0..f1a69ecf 100644 --- a/dns/transport_manager.go +++ b/dns/transport_manager.go @@ -2,30 +2,24 @@ package dns import ( "context" - "io" "os" "strings" "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.DNSTransportManager = (*TransportManager)(nil) type TransportManager struct { - logger log.ContextLogger registry adapter.DNSTransportRegistry outbound adapter.OutboundManager defaultTag string access sync.RWMutex - started bool - stage adapter.StartStage transports []adapter.DNSTransport transportByTag map[string]adapter.DNSTransport defaultTransport adapter.DNSTransport @@ -33,9 +27,8 @@ type TransportManager struct { fakeIPTransport adapter.FakeIPTransport } -func NewTransportManager(logger logger.ContextLogger, registry adapter.DNSTransportRegistry, outbound adapter.OutboundManager, defaultTag string) *TransportManager { +func NewTransportManager(registry adapter.DNSTransportRegistry, outbound adapter.OutboundManager, defaultTag string) *TransportManager { return &TransportManager{ - logger: logger, registry: registry, outbound: outbound, defaultTag: defaultTag, @@ -47,13 +40,8 @@ func (m *TransportManager) Initialize(defaultTransportFallback func() (adapter.D m.defaultTransportFallback = defaultTransportFallback } -func (m *TransportManager) Start(stage adapter.StartStage) error { +func (m *TransportManager) Start(stage adapter.StartStage, scope *adapter.Scope) error { m.access.Lock() - if m.started && m.stage >= stage { - panic("already started") - } - m.started = true - m.stage = stage if stage == adapter.StartStateInitialize { if m.defaultTag != "" && m.defaultTransport == nil { m.access.Unlock() @@ -73,19 +61,19 @@ func (m *TransportManager) Start(stage adapter.StartStage) error { transports := m.transports m.access.Unlock() if stage == adapter.StartStateStart { - return m.startTransports(transports) + return m.startTransports(scope, transports) } for _, transport := range transports { - err := transport.Start(stage) + name := "dns/" + transport.Type() + "[" + transport.Tag() + "]" + err := scope.Start(name, transport, stage) if err != nil { - return E.Cause(err, stage, " dns/", transport.Type(), "[", transport.Tag(), "]") + return err } } return nil } -func (m *TransportManager) startTransports(transports []adapter.DNSTransport) error { - monitor := taskmonitor.New(m.logger, C.StartTimeout) +func (m *TransportManager) startTransports(scope *adapter.Scope, transports []adapter.DNSTransport) error { started := make(map[string]bool) for { canContinue := false @@ -103,13 +91,10 @@ func (m *TransportManager) startTransports(transports []adapter.DNSTransport) er } started[transportTag] = true canContinue = true - if starter, isStarter := transportToStart.(adapter.Lifecycle); isStarter { - monitor.Start("start dns/", transportToStart.Type(), "[", transportTag, "]") - err := starter.Start(adapter.StartStateStart) - monitor.Finish() - if err != nil { - return E.Cause(err, "start dns/", transportToStart.Type(), "[", transportTag, "]") - } + name := "dns/" + transportToStart.Type() + "[" + transportTag + "]" + err := scope.Start(name, transportToStart, adapter.StartStateStart) + if err != nil { + return err } } if len(started) == len(transports) { @@ -142,30 +127,6 @@ func (m *TransportManager) startTransports(transports []adapter.DNSTransport) er return nil } -func (m *TransportManager) Close() error { - monitor := taskmonitor.New(m.logger, C.StopTimeout) - m.access.Lock() - if !m.started { - m.access.Unlock() - return nil - } - m.started = false - transports := m.transports - m.transports = nil - m.access.Unlock() - var err error - for _, transport := range transports { - if closer, isCloser := transport.(io.Closer); isCloser { - monitor.Start("close server/", transport.Type(), "[", transport.Tag(), "]") - err = E.Append(err, closer.Close(), func(err error) error { - return E.Cause(err, "close server/", transport.Type(), "[", transport.Tag(), "]") - }) - monitor.Finish() - } - } - return nil -} - func (m *TransportManager) Transports() []adapter.DNSTransport { m.access.RLock() defer m.access.RUnlock() diff --git a/experimental/cachefile/cache.go b/experimental/cachefile/cache.go index 0d714a60..3cbec734 100644 --- a/experimental/cachefile/cache.go +++ b/experimental/cachefile/cache.go @@ -140,10 +140,16 @@ func (c *CacheFile) SetDisableExpire(disableExpire bool) { c.disableExpire = disableExpire } -func (c *CacheFile) Start(stage adapter.StartStage) error { +func (c *CacheFile) Start(stage adapter.StartStage, scope *adapter.Scope) error { switch stage { case adapter.StartStateInitialize: - return c.start() + err := c.start() + if err != nil { + return err + } + scope.Add(func() error { + return c.database().Close() + }) case adapter.StartStateStart: c.startCacheCleanup() } @@ -239,16 +245,6 @@ func (c *CacheFile) start() error { return nil } -func (c *CacheFile) Close() error { - c.dbAccess.RLock() - db := c.DB - c.dbAccess.RUnlock() - if db == nil { - return nil - } - return db.Close() -} - func checkDatabase(db *bbolt.DB) error { return db.View(func(tx *bbolt.Tx) error { var checkErr error diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go index b9222ad5..6623906a 100644 --- a/experimental/clashapi/server.go +++ b/experimental/clashapi/server.go @@ -21,7 +21,6 @@ import ( "github.com/sagernet/sing-box/experimental/clashmode" "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/json" "github.com/sagernet/sing/service" @@ -147,7 +146,7 @@ func (s *Server) Name() string { return "clash server" } -func (s *Server) Start(stage adapter.StartStage) error { +func (s *Server) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStarted { return nil } @@ -168,6 +167,7 @@ func (s *Server) Start(stage adapter.StartStage) error { if err != nil { return E.Cause(err, "external controller listen error") } + scope.Add(s.httpServer.Close) s.logger.Info("restful api listening at ", listener.Addr()) go func() { err = s.httpServer.Serve(listener) @@ -179,12 +179,6 @@ func (s *Server) Start(stage adapter.StartStage) error { return nil } -func (s *Server) Close() error { - return common.Close( - common.PtrOrNil(s.httpServer), - ) -} - func authentication(serverSecret string) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { fn := func(w http.ResponseWriter, r *http.Request) { diff --git a/experimental/clashmode/manager.go b/experimental/clashmode/manager.go index 6491d754..ae6fd91d 100644 --- a/experimental/clashmode/manager.go +++ b/experimental/clashmode/manager.go @@ -43,7 +43,7 @@ func (m *Manager) Name() string { return "clash mode manager" } -func (m *Manager) Start(stage adapter.StartStage) error { +func (m *Manager) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } @@ -59,10 +59,6 @@ func (m *Manager) Start(stage adapter.StartStage) error { return nil } -func (m *Manager) Close() error { - return nil -} - func (m *Manager) Mode() string { return m.mode } diff --git a/experimental/libbox/dns.go b/experimental/libbox/dns.go index 7fcef2ce..3b96b621 100644 --- a/experimental/libbox/dns.go +++ b/experimental/libbox/dns.go @@ -46,15 +46,11 @@ func newPlatformTransport(ctx context.Context, logger log.ContextLogger, iif Loc }, nil } -func (p *platformTransport) Start(stage adapter.StartStage) error { +func (p *platformTransport) Start(stage adapter.StartStage, scope *adapter.Scope) error { p.preferredResolver.Start(stage) return nil } -func (p *platformTransport) Close() error { - return nil -} - func (p *platformTransport) Reset() { } diff --git a/experimental/libbox/http.go b/experimental/libbox/http.go index c9ee3787..b2b029a4 100644 --- a/experimental/libbox/http.go +++ b/experimental/libbox/http.go @@ -18,8 +18,10 @@ import ( "strconv" "sync" + "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/certificate" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/bufio" @@ -69,10 +71,10 @@ var ( ) type httpClient struct { - tls tls.Config - client http.Client - transport http.Transport - store *certificate.Store + tls tls.Config + client http.Client + transport http.Transport + storeScope *adapter.Scope } func NewHTTPClient() HTTPClient { @@ -87,6 +89,11 @@ func NewHTTPClient() HTTPClient { if err != nil { panic(E.Cause(err, "initialize certificate store")) } + client.storeScope = adapter.NewScope(context.Background(), log.NewNOPFactory().Logger()) + err = store.Start(adapter.StartStateInitialize, client.storeScope) + if err != nil { + panic(E.Cause(err, "initialize certificate store")) + } client.tls.RootCAs = store.Pool() } return client @@ -161,8 +168,8 @@ func (c *httpClient) NewRequest() HTTPRequest { } func (c *httpClient) Close() { - if c.store != nil { - c.store.Close() + if c.storeScope != nil { + c.storeScope.Close() } c.transport.CloseIdleConnections() } diff --git a/experimental/v2rayapi/server.go b/experimental/v2rayapi/server.go index 8ebae1c4..b5612ada 100644 --- a/experimental/v2rayapi/server.go +++ b/experimental/v2rayapi/server.go @@ -24,7 +24,6 @@ var _ adapter.V2RayServer = (*Server)(nil) type Server struct { logger log.Logger listen string - tcpListener net.Listener grpcServer *grpc.Server statsService *StatsService } @@ -48,33 +47,27 @@ func (s *Server) Name() string { return "v2ray server" } -func (s *Server) Start(stage adapter.StartStage) error { - if stage != adapter.StartStatePostStart { - return nil - } - listener, err := net.Listen("tcp", s.listen) - if err != nil { - return err - } - s.logger.Info("grpc server started at ", listener.Addr()) - s.tcpListener = listener - go func() { - err = s.grpcServer.Serve(listener) - if err != nil && !errors.Is(err, http.ErrServerClosed) { - s.logger.Error(err) +func (s *Server) Start(stage adapter.StartStage, scope *adapter.Scope) error { + switch stage { + case adapter.StartStateInitialize: + scope.Add(func() error { + s.grpcServer.Stop() + return nil + }) + case adapter.StartStatePostStart: + listener, err := net.Listen("tcp", s.listen) + if err != nil { + return err } - }() - return nil -} - -func (s *Server) Close() error { - if s.grpcServer != nil { - s.grpcServer.Stop() + s.logger.Info("grpc server started at ", listener.Addr()) + go func() { + err = s.grpcServer.Serve(listener) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + s.logger.Error(err) + } + }() } - return common.Close( - common.PtrOrNil(s.grpcServer), - s.tcpListener, - ) + return nil } func (s *Server) StatsService() adapter.ConnectionTracker { diff --git a/go.mod b/go.mod index 35df2eae..263a519a 100644 --- a/go.mod +++ b/go.mod @@ -46,7 +46,7 @@ require ( github.com/sagernet/nftables v0.3.0-mod.4 github.com/sagernet/quic-go v0.61.0-sing-box-mod.9 github.com/sagernet/sing v0.9.7-0.20260929150544-0ad23b637bd4 - github.com/sagernet/sing-cloudflared v0.1.4-0.20260929150702-b3a1e8f3018c + github.com/sagernet/sing-cloudflared v0.1.4-0.20261002084126-c1255ae368f2 github.com/sagernet/sing-mux v0.3.10-0.20260929204512-caf09fe32475 github.com/sagernet/sing-openconnect v0.1.1-0.20260929151225-cbd68c45de58 github.com/sagernet/sing-openvpn v0.1.1-0.20260929151220-7c3045dbddce diff --git a/go.sum b/go.sum index 481f6c6a..81bbade2 100644 --- a/go.sum +++ b/go.sum @@ -320,8 +320,8 @@ github.com/sagernet/quic-go v0.61.0-sing-box-mod.9 h1:Lojx77uC63TxZO38Z1OSo6aOoB github.com/sagernet/quic-go v0.61.0-sing-box-mod.9/go.mod h1:hmLC8GJPp+BrpBgrgJnacvI4fZGIhasuy1ROJJ8GR4E= github.com/sagernet/sing v0.9.7-0.20260929150544-0ad23b637bd4 h1:d4WT3KO4zhp9qzPHUZCKqCP+ZmP1GXBsAmReqlnE4zA= github.com/sagernet/sing v0.9.7-0.20260929150544-0ad23b637bd4/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= -github.com/sagernet/sing-cloudflared v0.1.4-0.20260929150702-b3a1e8f3018c h1:36zXD/qnDk7sh/J4zSEJsnHf4W3TYNCbmjo9MNIlrE4= -github.com/sagernet/sing-cloudflared v0.1.4-0.20260929150702-b3a1e8f3018c/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= +github.com/sagernet/sing-cloudflared v0.1.4-0.20261002084126-c1255ae368f2 h1:+AfbAXGEV8tezIYHeShzwHm7IMUE5kYGVFLo0ly7W24= +github.com/sagernet/sing-cloudflared v0.1.4-0.20261002084126-c1255ae368f2/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.10-0.20260929204512-caf09fe32475 h1:aG0DqHEOjax7DyfE1GTAXZotOg3QGVCz3/8rCSua2kg= github.com/sagernet/sing-mux v0.3.10-0.20260929204512-caf09fe32475/go.mod h1:jricpwCOndDsTEM1TM3piJvhL/2Mm7OhLkm+JadJJ1Q= github.com/sagernet/sing-openconnect v0.1.1-0.20260929151225-cbd68c45de58 h1:WOPVlvbw3zGl4IzpRcfqB901blgDKuiplVrpptf/TOY= diff --git a/protocol/anytls/inbound.go b/protocol/anytls/inbound.go index f85b8f78..54c53d18 100644 --- a/protocol/anytls/inbound.go +++ b/protocol/anytls/inbound.go @@ -79,7 +79,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo return inbound, nil } -func (h *Inbound) Start(stage adapter.StartStage) error { +func (h *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } @@ -88,12 +88,14 @@ func (h *Inbound) Start(stage adapter.StartStage) error { if err != nil { return err } + scope.Add(h.tlsConfig.Close) } - return h.listener.Start() -} - -func (h *Inbound) Close() error { - return common.Close(h.listener, h.tlsConfig) + err := h.listener.Start() + if err != nil { + return err + } + scope.Add(h.listener.Close) + return nil } func (h *Inbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { diff --git a/protocol/anytls/outbound.go b/protocol/anytls/outbound.go index b4c07521..30d262ca 100644 --- a/protocol/anytls/outbound.go +++ b/protocol/anytls/outbound.go @@ -36,7 +36,6 @@ type Outbound struct { tlsConfig tls.Config clientOptions anytls.ClientConfig clientMetadata string - client *anytls.Client sessionClient *session.Client uotClient *uot.Client logger log.ContextLogger @@ -89,7 +88,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL return outbound, nil } -func (h *Outbound) Start(stage adapter.StartStage) error { +func (h *Outbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateInitialize { return nil } @@ -97,7 +96,7 @@ func (h *Outbound) Start(stage adapter.StartStage) error { if err != nil { return err } - h.client = client + scope.Add(client.Close) h.sessionClient = sessionClientOf(client) h.uotClient = &uot.Client{ Dialer: anytlsDialer(h.createProxy), @@ -160,7 +159,3 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n h.logger.InfoContext(ctx, "outbound UoT packet connection to ", destination) return h.uotClient.ListenPacket(ctx, destination) } - -func (h *Outbound) Close() error { - return common.Close(common.PtrOrNil(h.client)) -} diff --git a/protocol/bridge/backend.go b/protocol/bridge/backend.go index 0c33c753..2c48af97 100644 --- a/protocol/bridge/backend.go +++ b/protocol/bridge/backend.go @@ -41,16 +41,13 @@ type backendBase struct { returnAccess sync.Mutex returnPaths []tun.Return - egressAccess sync.Mutex - forwardingRestore []sysctlState - unregister func() + egressAccess sync.Mutex session adapter.BridgeSession currentEgress string - closeOnce sync.Once - closed chan struct{} - readDone chan struct{} + closed <-chan struct{} + readGroup sync.WaitGroup } func (b *backendBase) init(ctx context.Context, logger logger.ContextLogger, networkManager adapter.NetworkManager, tag string, options option.BridgeOutboundOptions) error { @@ -100,12 +97,14 @@ func (b *backendBase) DetachReturn(returnPath tun.Return) error { return nil } -func (b *backendBase) registerMonitors(syncFunc func()) { - var unregisterFuncs []func() +func (b *backendBase) registerMonitors(scope *adapter.Scope, syncFunc func()) { networkMonitor := b.networkManager.NetworkMonitor() if networkMonitor != nil { networkElement := networkMonitor.RegisterCallback(syncFunc) - unregisterFuncs = append(unregisterFuncs, func() { networkMonitor.UnregisterCallback(networkElement) }) + scope.Add(func() error { + networkMonitor.UnregisterCallback(networkElement) + return nil + }) } else if b.boundInterface != "" { b.logger.Debug("network monitor unavailable, pinned egress will not track interface changes") } @@ -113,14 +112,10 @@ func (b *backendBase) registerMonitors(syncFunc func()) { interfaceMonitor := b.networkManager.InterfaceMonitor() if interfaceMonitor != nil { interfaceElement := interfaceMonitor.RegisterCallback(func(_ *control.Interface, _ int) { syncFunc() }) - unregisterFuncs = append(unregisterFuncs, func() { interfaceMonitor.UnregisterCallback(interfaceElement) }) - } - } - if len(unregisterFuncs) > 0 { - b.unregister = func() { - for _, unregisterFunc := range unregisterFuncs { - unregisterFunc() - } + scope.Add(func() error { + interfaceMonitor.UnregisterCallback(interfaceElement) + return nil + }) } } } @@ -166,7 +161,6 @@ func (b *backendBase) resolveEgress() string { } func (b *backendBase) readLoop() { - defer close(b.readDone) buffer := make([]byte, tun.PacketOffset+bridgeTunMTU) for { n, err := b.tunInterface.Read(buffer) diff --git a/protocol/bridge/backend_darwin.go b/protocol/bridge/backend_darwin.go index 99580bbd..4da636d7 100644 --- a/protocol/bridge/backend_darwin.go +++ b/protocol/bridge/backend_darwin.go @@ -72,24 +72,30 @@ func newBackend(ctx context.Context, logger logger.ContextLogger, networkManager return instance, nil } -func (b *backendDarwin) Start(stage adapter.StartStage) error { - if stage != adapter.StartStateStart { - return nil - } - err := b.start() - if err != nil { - b.Close() - return err +func (b *backendDarwin) Start(stage adapter.StartStage, scope *adapter.Scope) error { + switch stage { + case adapter.StartStateInitialize: + scope.Add(func() error { + releaseBridgeIndex(b.index) + return nil + }) + case adapter.StartStateStart: + b.closed = scope.Context().Done() + if b.platform != nil { + return b.startPlatform(scope) + } + return b.start(scope) } return nil } -func (b *backendDarwin) start() error { - if b.platform != nil { - return b.startPlatform() - } +func (b *backendDarwin) start(scope *adapter.Scope) error { b.tunName = tun.CalculateInterfaceName(b.bridgeName) b.anchorName = "com.apple/sing-box-" + b.tunName + scope.Add(func() error { + b.readGroup.Wait() + return nil + }) tunInterface, err := tun.New(tun.Options{ Name: b.tunName, MTU: bridgeTunMTUDarwin, @@ -102,12 +108,17 @@ func (b *backendDarwin) start() error { if err != nil { return E.Cause(err, "create bridge tun") } + scope.Add(tunInterface.Close) b.tunInterface = tunInterface err = tunInterface.Start() if err != nil { return E.Cause(err, "start bridge tun") } - b.forwardingRestore = enableDarwinForwarding(b.logger, b.inet4Port.IsValid(), b.inet6Port.IsValid()) + forwardingRestore := enableDarwinForwarding(b.logger, b.inet4Port.IsValid(), b.inet6Port.IsValid()) + scope.Add(func() error { + restoreDarwinForwarding(forwardingRestore) + return nil + }) err = assignBridgePortAddress(b.tunName, b.inet4Local, b.inet4Port) if err != nil { return E.Cause(err, "add bridge route") @@ -121,23 +132,31 @@ func (b *backendDarwin) start() error { if err != nil { return E.Cause(err, "enable pf") } + scope.Add(func() error { + _ = b.pfDevice.StopReference(b.pfToken) + return b.pfDevice.Close() + }) dropRules := bridgeDropRules(b.tunName, b.inet4Port, b.inet6Port) err = b.pfDevice.LoadAnchor(b.anchorName, dropRules) if err != nil { return E.Cause(err, "initialize bridge pf rules") } + scope.Add(func() error { + b.egressAccess.Lock() + _ = b.pfDevice.LoadAnchor(b.anchorName, nil) + b.egressAccess.Unlock() + return nil + }) b.currentRules = dropRules b.batchTUN = tunInterface.(tun.DarwinTUN) - b.closed = make(chan struct{}) - b.readDone = make(chan struct{}) - b.registerMonitors(b.syncEgress) + b.registerMonitors(scope, b.syncEgress) b.syncEgress() - go b.batchReadLoop() + b.readGroup.Go(b.batchReadLoop) b.logger.Info("bridge started at ", b.tunName, " (masquerade, egress ", b.egressLabel(), ")") return nil } -func (b *backendDarwin) startPlatform() error { +func (b *backendDarwin) startPlatform(scope *adapter.Scope) error { session, err := b.platform.CreateBridge(adapter.BridgeOptions{ BridgeName: b.bridgeName, MTU: bridgeTunMTUDarwin, @@ -148,11 +167,16 @@ func (b *backendDarwin) startPlatform() error { if err != nil { return E.Cause(err, "create bridge") } + scope.Add(session.Close) b.session = session b.tunName = session.Name() if !session.Inet6Active() { b.inet6Port = netip.Addr{} } + scope.Add(func() error { + b.readGroup.Wait() + return nil + }) tunInterface, err := tun.New(tun.Options{ Name: b.tunName, MTU: bridgeTunMTUDarwin, @@ -164,17 +188,16 @@ func (b *backendDarwin) startPlatform() error { if err != nil { return E.Cause(err, "create bridge tun") } + scope.Add(tunInterface.Close) b.tunInterface = tunInterface err = tunInterface.Start() if err != nil { return E.Cause(err, "start bridge tun") } b.batchTUN = tunInterface.(tun.DarwinTUN) - b.closed = make(chan struct{}) - b.readDone = make(chan struct{}) - b.registerMonitors(b.syncSessionEgress) + b.registerMonitors(scope, b.syncSessionEgress) b.syncSessionEgress() - go b.batchReadLoop() + b.readGroup.Go(b.batchReadLoop) b.logger.Info("bridge started at ", b.tunName, " (platform, egress ", b.egressLabel(), ")") return nil } @@ -186,41 +209,6 @@ func (b *backendDarwin) egressLabel() string { return "auto" } -func (b *backendDarwin) Close() error { - b.closeOnce.Do(func() { - if b.closed != nil { - close(b.closed) - } - if b.unregister != nil { - b.unregister() - } - if b.pfDevice != nil && b.anchorName != "" { - b.egressAccess.Lock() - _ = b.pfDevice.LoadAnchor(b.anchorName, nil) - b.egressAccess.Unlock() - } - restoreDarwinForwarding(b.forwardingRestore) - b.forwardingRestore = nil - if b.pfDevice != nil { - if b.pfToken != 0 { - _ = b.pfDevice.StopReference(b.pfToken) - } - _ = b.pfDevice.Close() - } - if b.tunInterface != nil { - b.tunInterface.Close() - } - if b.readDone != nil { - <-b.readDone - } - if b.session != nil { - _ = b.session.Close() - } - releaseBridgeIndex(b.index) - }) - return nil -} - func (b *backendDarwin) PortMTU() uint32 { return bridgeTunMTUDarwin } @@ -247,7 +235,6 @@ func (b *backendDarwin) WritePackets(packets [][]byte) error { } func (b *backendDarwin) batchReadLoop() { - defer close(b.readDone) headroom := -1 var buffers [][]byte var batch [][]byte diff --git a/protocol/bridge/backend_linux.go b/protocol/bridge/backend_linux.go index 8634ccfd..ce6e1176 100644 --- a/protocol/bridge/backend_linux.go +++ b/protocol/bridge/backend_linux.go @@ -63,24 +63,30 @@ func newBackend(ctx context.Context, logger logger.ContextLogger, networkManager return instance, nil } -func (b *backendLinux) Start(stage adapter.StartStage) error { - if stage != adapter.StartStateStart { - return nil - } - err := b.start() - if err != nil { - b.Close() - return err +func (b *backendLinux) Start(stage adapter.StartStage, scope *adapter.Scope) error { + switch stage { + case adapter.StartStateInitialize: + scope.Add(func() error { + releaseBridgeIndex(b.index) + return nil + }) + case adapter.StartStateStart: + b.closed = scope.Context().Done() + if b.platform != nil { + return b.startPlatform(scope) + } + return b.start(scope) } return nil } -func (b *backendLinux) start() error { - if b.platform != nil { - return b.startPlatform() - } +func (b *backendLinux) start(scope *adapter.Scope) error { b.tunName = tun.CalculateInterfaceName(b.bridgeName) b.nftTableName = "sing-box-" + b.tunName + scope.Add(func() error { + b.readGroup.Wait() + return nil + }) tunInterface, err := tun.New(tun.Options{ Name: b.tunName, MTU: bridgeTunMTU, @@ -92,6 +98,7 @@ func (b *backendLinux) start() error { if err != nil { return E.Cause(err, "create bridge tun") } + scope.Add(tunInterface.Close) b.tunInterface = tunInterface err = tunInterface.Start() if err != nil { @@ -112,13 +119,38 @@ func (b *backendLinux) start() error { if err != nil { return E.Cause(err, "set up bridge netfilter") } + scope.Add(func() error { + b.egressAccess.Lock() + cleanupBridgeNetfilter(b.nftTableName) + b.egressAccess.Unlock() + return nil + }) if !inet6Active { b.inet6Port = netip.Addr{} } - b.forwardingRestore = enableBridgeForwarding(b.logger, b.tunName, b.inet4Port.IsValid(), b.inet6Port.IsValid()) + forwardingRestore := enableBridgeForwarding(b.logger, b.tunName, b.inet4Port.IsValid(), b.inet6Port.IsValid()) + scope.Add(func() error { + restoreBridgeForwarding(forwardingRestore) + return nil + }) + if b.routeTable != 0 { + scope.Add(func() error { + b.egressAccess.Lock() + flushBridgeRouteTable(b.routeTable) + b.egressAccess.Unlock() + return nil + }) + } if b.boundInterface != "" { b.syncEgress() } + scope.Add(func() error { + b.egressAccess.Lock() + removeBridgeFamily(b.tunName, b.ruleIndex, b.routeTable, unix.AF_INET, b.inet4Port) + removeBridgeFamily(b.tunName, b.ruleIndex, b.routeTable, unix.AF_INET6, b.inet6Port) + b.egressAccess.Unlock() + return nil + }) err = setupBridgeFamily(b.tunName, b.ruleIndex, b.routeTable, unix.AF_INET, b.inet4Port) if err != nil { return E.Cause(err, "set up bridge routing") @@ -129,12 +161,10 @@ func (b *backendLinux) start() error { removeBridgeFamily(b.tunName, b.ruleIndex, b.routeTable, unix.AF_INET6, b.inet6Port) b.inet6Port = netip.Addr{} } - b.closed = make(chan struct{}) - b.readDone = make(chan struct{}) if b.batchTUN != nil { - go b.batchReadLoop() + b.readGroup.Go(b.batchReadLoop) } else { - go b.readLoop() + b.readGroup.Go(b.readLoop) } egress := "auto" if b.boundInterface != "" { @@ -142,7 +172,10 @@ func (b *backendLinux) start() error { monitor := b.networkManager.NetworkMonitor() if monitor != nil { element := monitor.RegisterCallback(func() { b.syncEgress() }) - b.unregister = func() { monitor.UnregisterCallback(element) } + scope.Add(func() error { + monitor.UnregisterCallback(element) + return nil + }) } else { b.logger.Debug("network monitor unavailable, pinned egress will not track interface changes") } @@ -151,7 +184,10 @@ func (b *backendLinux) start() error { monitor := b.networkManager.InterfaceMonitor() if monitor != nil { element := monitor.RegisterCallback(func(_ *control.Interface, _ int) { b.updateClamp() }) - b.unregister = func() { monitor.UnregisterCallback(element) } + scope.Add(func() error { + monitor.UnregisterCallback(element) + return nil + }) } b.updateClamp() } @@ -163,7 +199,7 @@ func (b *backendLinux) start() error { return nil } -func (b *backendLinux) startPlatform() error { +func (b *backendLinux) startPlatform(scope *adapter.Scope) error { session, err := b.platform.CreateBridge(adapter.BridgeOptions{ BridgeName: b.bridgeName, MTU: bridgeTunMTU, @@ -175,11 +211,16 @@ func (b *backendLinux) startPlatform() error { if err != nil { return E.Cause(err, "create bridge") } + scope.Add(session.Close) b.session = session b.tunName = session.Name() if !session.Inet6Active() { b.inet6Port = netip.Addr{} } + scope.Add(func() error { + b.readGroup.Wait() + return nil + }) tunInterface, err := tun.New(tun.Options{ Name: b.tunName, MTU: bridgeTunMTU, @@ -190,6 +231,7 @@ func (b *backendLinux) startPlatform() error { if err != nil { return E.Cause(err, "create bridge tun") } + scope.Add(tunInterface.Close) b.tunInterface = tunInterface err = tunInterface.Start() if err != nil { @@ -204,17 +246,18 @@ func (b *backendLinux) startPlatform() error { b.writeBuffers[i] = make([]byte, b.writeHeadroom+maxPacketLength) } } - b.closed = make(chan struct{}) - b.readDone = make(chan struct{}) if b.batchTUN != nil { - go b.batchReadLoop() + b.readGroup.Go(b.batchReadLoop) } else { - go b.readLoop() + b.readGroup.Go(b.readLoop) } monitor := b.networkManager.InterfaceMonitor() if monitor != nil { element := monitor.RegisterCallback(func(_ *control.Interface, _ int) { b.syncSessionEgress() }) - b.unregister = func() { monitor.UnregisterCallback(element) } + scope.Add(func() error { + monitor.UnregisterCallback(element) + return nil + }) } b.syncSessionEgress() egress := "auto" @@ -225,41 +268,6 @@ func (b *backendLinux) startPlatform() error { return nil } -func (b *backendLinux) Close() error { - b.closeOnce.Do(func() { - if b.closed != nil { - close(b.closed) - } - if b.unregister != nil { - b.unregister() - } - if b.tunInterface != nil { - b.tunInterface.Close() - } - if b.readDone != nil { - <-b.readDone - } - if b.session != nil { - _ = b.session.Close() - } else { - b.egressAccess.Lock() - if b.tunName != "" { - cleanupBridgeNetfilter(b.nftTableName) - removeBridgeFamily(b.tunName, b.ruleIndex, b.routeTable, unix.AF_INET, b.inet4Port) - removeBridgeFamily(b.tunName, b.ruleIndex, b.routeTable, unix.AF_INET6, b.inet6Port) - } - if b.routeTable != 0 { - flushBridgeRouteTable(b.routeTable) - } - b.egressAccess.Unlock() - restoreBridgeForwarding(b.forwardingRestore) - b.forwardingRestore = nil - } - releaseBridgeIndex(b.index) - }) - return nil -} - func (b *backendLinux) PortMTU() uint32 { return 0 } @@ -308,7 +316,6 @@ func (b *backendLinux) WritePackets(packets [][]byte) error { // BatchRead completes any kernel-deferred checksums while splitting GRO frames // (virtio NEEDS_CSUM), so unlike readLoop no checksum fix is needed here. func (b *backendLinux) batchReadLoop() { - defer close(b.readDone) batchSize := b.batchTUN.BatchSize() sizes := make([]int, batchSize) batch := make([][]byte, 0, batchSize) diff --git a/protocol/bridge/backend_windows.go b/protocol/bridge/backend_windows.go index ab66fcec..af877ce1 100644 --- a/protocol/bridge/backend_windows.go +++ b/protocol/bridge/backend_windows.go @@ -140,20 +140,21 @@ func newBackend(ctx context.Context, logger logger.ContextLogger, networkManager return instance, nil } -func (b *backendWindows) Start(stage adapter.StartStage) error { - if stage != adapter.StartStateStart { - return nil - } - err := b.start() - if err != nil { - b.Close() - return err +func (b *backendWindows) Start(stage adapter.StartStage, scope *adapter.Scope) error { + switch stage { + case adapter.StartStateInitialize: + scope.Add(func() error { + releaseBridgeIndex(b.index) + return nil + }) + case adapter.StartStateStart: + return b.start(scope) } return nil } -func (b *backendWindows) start() error { - b.closed = make(chan struct{}) +func (b *backendWindows) start(scope *adapter.Scope) error { + b.closed = scope.Context().Done() state := b.currentEgressState() b.egress.Store(state) @@ -162,11 +163,16 @@ func (b *backendWindows) start() error { if err != nil { return err } + scope.Add(func() error { + b.reservation.Close() + return nil + }) injectHandle, err := windivert.Open(nil, windivert.LayerNetwork, windivert.PriorityHighest, windivert.FlagSendOnly) if err != nil { return E.Cause(err, "bridge: open injection handle (Administrator required)") } + scope.Add(injectHandle.Close) b.injectHandle = injectHandle b.sendBuffer = make([]byte, 0, bridgeBatchBufferSize) b.sendAddrs = make([]windivert.Address, 0, windivert.BatchMax) @@ -177,8 +183,14 @@ func (b *backendWindows) start() error { if err != nil { return err } + scope.Add(func() error { + b.egressAccess.Lock() + b.closeDivertersLocked() + b.egressAccess.Unlock() + return nil + }) - b.registerMonitors(b.syncEgress) + b.registerMonitors(scope, b.syncEgress) b.syncEgress() state = b.egress.Load() if !state.inet4.IsValid() && !state.inet6.IsValid() { @@ -799,27 +811,6 @@ func sortSegments(segments []localSegment) { }) } -func (b *backendWindows) Close() error { - b.closeOnce.Do(func() { - if b.closed != nil { - close(b.closed) - } - if b.unregister != nil { - b.unregister() - } - b.egressAccess.Lock() - b.closeDivertersLocked() - b.egressAccess.Unlock() - if b.injectHandle != nil { - b.injectHandle.Close() - } - b.reservation.Close() - b.reservation = nil - releaseBridgeIndex(b.index) - }) - return nil -} - type transportInfo struct { protocol tcpip.TransportProtocolNumber transport []byte diff --git a/protocol/bridge/outbound.go b/protocol/bridge/outbound.go index cd952841..e4db72d4 100644 --- a/protocol/bridge/outbound.go +++ b/protocol/bridge/outbound.go @@ -57,12 +57,8 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL }, nil } -func (o *Outbound) Start(stage adapter.StartStage) error { - return o.backend.Start(stage) -} - -func (o *Outbound) Close() error { - return o.backend.Close() +func (o *Outbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { + return o.backend.Start(stage, scope) } func (o *Outbound) PreferredDomain(metadata *adapter.InboundContext, domain string) bool { diff --git a/protocol/cloudflare/inbound.go b/protocol/cloudflare/inbound.go index d69dac0b..8d3d848e 100644 --- a/protocol/cloudflare/inbound.go +++ b/protocol/cloudflare/inbound.go @@ -92,15 +92,16 @@ type Inbound struct { references []string } -func (i *Inbound) Start(stage adapter.StartStage) error { +func (i *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } - return i.service.Start() -} - -func (i *Inbound) Close() error { - return i.service.Close() + err := i.service.Start() + if err != nil { + return err + } + scope.Add(i.service.Close) + return nil } type routerDialer struct { diff --git a/protocol/direct/inbound.go b/protocol/direct/inbound.go index 34ebbb15..603ba688 100644 --- a/protocol/direct/inbound.go +++ b/protocol/direct/inbound.go @@ -70,21 +70,22 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo return inbound, nil } -func (i *Inbound) Start(stage adapter.StartStage) error { +func (i *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } - return i.listener.Start() + err := i.listener.Start() + if err != nil { + return err + } + scope.Add(i.listener.Close) + return nil } func (i *Inbound) InterfaceUpdated(ctx context.Context) { i.udpNat.Purge() } -func (i *Inbound) Close() error { - return i.listener.Close() -} - func (i *Inbound) NewPacket(buffer *buf.Buffer, source M.Socksaddr) { i.udpNat.NewPacket([][]byte{buffer.Bytes()}, source, i.listener.UDPAddr(), nil) } diff --git a/protocol/direct/outbound.go b/protocol/direct/outbound.go index 020b3d62..af8826b8 100644 --- a/protocol/direct/outbound.go +++ b/protocol/direct/outbound.go @@ -88,8 +88,12 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL return outbound, nil } -func (h *Outbound) Start(stage adapter.StartStage) error { +func (h *Outbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { switch stage { + case adapter.StartStateInitialize: + if h.icmpPort != nil { + scope.Add(h.icmpPort.Close) + } case adapter.StartStatePostStart, adapter.StartStateStarted: if len(h.myAddresses.Load()) == 0 { h.fetchMyAddresses() @@ -205,13 +209,6 @@ func (h *Outbound) WritePackets(packets [][]byte) error { return h.icmpPort.WritePackets(packets) } -func (h *Outbound) Close() error { - if h.icmpPort != nil { - return h.icmpPort.Close() - } - return nil -} - func (h *Outbound) DialParallel(ctx context.Context, network string, destination M.Socksaddr, destinationAddresses []netip.Addr) (net.Conn, error) { if h.isMyLoopbackAddress(destinationAddresses...) { return nil, E.New("loopback connection to TUN range") diff --git a/protocol/group/selector.go b/protocol/group/selector.go index d23ffeb2..c3aaec5a 100644 --- a/protocol/group/selector.go +++ b/protocol/group/selector.go @@ -73,7 +73,10 @@ func (s *Selector) Network() []string { return selected.Network() } -func (s *Selector) Start() error { +func (s *Selector) Start(stage adapter.StartStage, scope *adapter.Scope) error { + if stage != adapter.StartStateStart { + return nil + } for i, tag := range s.tags { detour, loaded := s.outbound.Outbound(tag) if !loaded { diff --git a/protocol/group/urltest.go b/protocol/group/urltest.go index 01964896..bc50ab70 100644 --- a/protocol/group/urltest.go +++ b/protocol/group/urltest.go @@ -71,7 +71,7 @@ func NewURLTest(ctx context.Context, router adapter.Router, logger log.ContextLo return outbound, nil } -func (s *URLTest) Start(stage adapter.StartStage) error { +func (s *URLTest) Start(stage adapter.StartStage, scope *adapter.Scope) error { switch stage { case adapter.StartStateStart: outbounds := make([]adapter.Outbound, 0, len(s.tags)) @@ -89,16 +89,11 @@ func (s *URLTest) Start(stage adapter.StartStage) error { s.group = group case adapter.StartStateStarted: s.group.PostStart() + scope.Add(s.group.Close) } return nil } -func (s *URLTest) Close() error { - return common.Close( - common.PtrOrNil(s.group), - ) -} - func (s *URLTest) Now() string { if s.group.selectedOutboundTCP != nil { return s.group.selectedOutboundTCP.Tag() diff --git a/protocol/http/inbound.go b/protocol/http/inbound.go index fe573ea1..6e0c3ebd 100644 --- a/protocol/http/inbound.go +++ b/protocol/http/inbound.go @@ -66,7 +66,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo return inbound, nil } -func (h *Inbound) Start(stage adapter.StartStage) error { +func (h *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } @@ -75,15 +75,14 @@ func (h *Inbound) Start(stage adapter.StartStage) error { if err != nil { return E.Cause(err, "create TLS config") } + scope.Add(h.tlsConfig.Close) } - return h.listener.Start() -} - -func (h *Inbound) Close() error { - return common.Close( - h.listener, - h.tlsConfig, - ) + err := h.listener.Start() + if err != nil { + return err + } + scope.Add(h.listener.Close) + return nil } func (h *Inbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { diff --git a/protocol/hysteria/inbound.go b/protocol/hysteria/inbound.go index a0f86c43..3a186025 100644 --- a/protocol/hysteria/inbound.go +++ b/protocol/hysteria/inbound.go @@ -147,7 +147,7 @@ func (h *Inbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) } -func (h *Inbound) Start(stage adapter.StartStage) error { +func (h *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } @@ -156,18 +156,17 @@ func (h *Inbound) Start(stage adapter.StartStage) error { if err != nil { return err } + scope.Add(h.tlsConfig.Close) } packetConn, err := h.listener.ListenUDP() if err != nil { return err } - return h.service.Start(packetConn) -} - -func (h *Inbound) Close() error { - return common.Close( - h.listener, - h.tlsConfig, - common.PtrOrNil(h.service), - ) + scope.Add(h.listener.Close) + err = h.service.Start(packetConn) + if err != nil { + return err + } + scope.Add(h.service.Close) + return nil } diff --git a/protocol/hysteria/outbound.go b/protocol/hysteria/outbound.go index a5b7b7c2..9e1244f4 100644 --- a/protocol/hysteria/outbound.go +++ b/protocol/hysteria/outbound.go @@ -131,6 +131,12 @@ func (h *Outbound) CloseIdleConnections() { h.client.CloseIdleConnections() } -func (h *Outbound) Close() error { - return h.client.CloseWithError(os.ErrClosed) +func (h *Outbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { + if stage != adapter.StartStateInitialize { + return nil + } + scope.Add(func() error { + return h.client.CloseWithError(os.ErrClosed) + }) + return nil } diff --git a/protocol/hysteria2/inbound.go b/protocol/hysteria2/inbound.go index f86e2402..8fd73329 100644 --- a/protocol/hysteria2/inbound.go +++ b/protocol/hysteria2/inbound.go @@ -268,7 +268,7 @@ func (h *Inbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) } -func (h *Inbound) Start(stage adapter.StartStage) error { +func (h *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } @@ -277,22 +277,21 @@ func (h *Inbound) Start(stage adapter.StartStage) error { if err != nil { return err } + scope.Add(h.tlsConfig.Close) } packetConn, err := h.listener.ListenUDP() if err != nil { return err } - return h.service.Start(packetConn) + scope.Add(h.listener.Close) + err = h.service.Start(packetConn) + if err != nil { + return err + } + scope.Add(h.service.Close) + return nil } func (h *Inbound) InterfaceUpdated(ctx context.Context) { h.service.Reset() } - -func (h *Inbound) Close() error { - return common.Close( - h.listener, - h.tlsConfig, - common.PtrOrNil(h.service), - ) -} diff --git a/protocol/hysteria2/outbound.go b/protocol/hysteria2/outbound.go index 398ee66b..14e761fb 100644 --- a/protocol/hysteria2/outbound.go +++ b/protocol/hysteria2/outbound.go @@ -223,6 +223,12 @@ func (h *Outbound) CloseIdleConnections() { h.client.CloseIdleConnections() } -func (h *Outbound) Close() error { - return h.client.CloseWithError(os.ErrClosed) +func (h *Outbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { + if stage != adapter.StartStateInitialize { + return nil + } + scope.Add(func() error { + return h.client.CloseWithError(os.ErrClosed) + }) + return nil } diff --git a/protocol/hysteria2/realm.go b/protocol/hysteria2/realm.go index de5bdd9f..a5b70330 100644 --- a/protocol/hysteria2/realm.go +++ b/protocol/hysteria2/realm.go @@ -122,7 +122,14 @@ func NewRealmService(ctx context.Context, logger log.ContextLogger, tag string, return s, nil } -func (s *RealmService) Start(stage adapter.StartStage) error { +func (s *RealmService) Start(stage adapter.StartStage, scope *adapter.Scope) error { + if stage == adapter.StartStateInitialize { + scope.Add(func() error { + s.cancel() + return nil + }) + return nil + } if stage != adapter.StartStateStart { return nil } @@ -131,11 +138,17 @@ func (s *RealmService) Start(stage adapter.StartStage) error { if err != nil { return E.Cause(err, "create TLS config") } + scope.Add(s.tlsConfig.Close) } tcpListener, err := s.listener.ListenTCP() if err != nil { return err } + scope.Add(s.listener.Close) + scope.Add(func() error { + s.server.closeAll() + return nil + }) if s.tlsConfig != nil { if !common.Contains(s.tlsConfig.NextProtos(), http2.NextProtoTLS) { s.tlsConfig.SetNextProtos(append([]string{"h2"}, s.tlsConfig.NextProtos()...)) @@ -148,15 +161,6 @@ func (s *RealmService) Start(stage adapter.StartStage) error { s.logger.Error("serve error: ", err) } }() + scope.Add(s.httpServer.Close) return nil } - -func (s *RealmService) Close() error { - s.cancel() - err := common.Close(common.PtrOrNil(s.httpServer)) - s.server.closeAll() - return E.Errors(err, common.Close( - common.PtrOrNil(s.listener), - s.tlsConfig, - )) -} diff --git a/protocol/mixed/inbound.go b/protocol/mixed/inbound.go index c6cd08cb..22c25f30 100644 --- a/protocol/mixed/inbound.go +++ b/protocol/mixed/inbound.go @@ -79,7 +79,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo return inbound, nil } -func (h *Inbound) Start(stage adapter.StartStage) error { +func (h *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } @@ -88,15 +88,14 @@ func (h *Inbound) Start(stage adapter.StartStage) error { if err != nil { return E.Cause(err, "create TLS config") } + scope.Add(h.tlsConfig.Close) } - return h.listener.Start() -} - -func (h *Inbound) Close() error { - return common.Close( - h.listener, - h.tlsConfig, - ) + err := h.listener.Start() + if err != nil { + return err + } + scope.Add(h.listener.Close) + return nil } func (h *Inbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { diff --git a/protocol/naive/inbound.go b/protocol/naive/inbound.go index dfa022b1..d19cc642 100644 --- a/protocol/naive/inbound.go +++ b/protocol/naive/inbound.go @@ -52,7 +52,6 @@ type Inbound struct { authenticator *auth.Authenticator tlsConfig tls.ServerConfig httpServer *http.Server - h3Server io.Closer } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.NaiveInboundOptions) (adapter.Inbound, error) { @@ -89,7 +88,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo return inbound, nil } -func (n *Inbound) Start(stage adapter.StartStage) error { +func (n *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } @@ -98,7 +97,9 @@ func (n *Inbound) Start(stage adapter.StartStage) error { if err != nil { return E.Cause(err, "create TLS config") } + scope.Add(n.tlsConfig.Close) } + scope.Add(n.listener.Close) if common.Contains(n.network, N.NetworkTCP) { tcpListener, err := n.listener.ListenTCP() if err != nil { @@ -126,12 +127,13 @@ func (n *Inbound) Start(stage adapter.StartStage) error { n.logger.Error("http server serve error: ", sErr) } }() + scope.Add(n.httpServer.Close) } if common.Contains(n.network, N.NetworkUDP) { http3Server, err := ConfigureHTTP3ListenerFunc(n.ctx, n.logger, n.listener, n, n.tlsConfig, n.options) if err == nil { - n.h3Server = http3Server + scope.Add(http3Server.Close) } else if len(n.network) > 1 { n.logger.Warn(E.Cause(err, "naive http3 disabled")) } else { @@ -142,15 +144,6 @@ func (n *Inbound) Start(stage adapter.StartStage) error { return nil } -func (n *Inbound) Close() error { - return common.Close( - n.listener, - common.PtrOrNil(n.httpServer), - n.h3Server, - n.tlsConfig, - ) -} - func (n *Inbound) ServeHTTP(writer http.ResponseWriter, request *http.Request) { ctx := log.ContextWithNewID(request.Context()) if request.Method != "CONNECT" { diff --git a/protocol/naive/outbound.go b/protocol/naive/outbound.go index aa2bb09b..c892aa7a 100644 --- a/protocol/naive/outbound.go +++ b/protocol/naive/outbound.go @@ -221,7 +221,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL }, nil } -func (h *Outbound) Start(stage adapter.StartStage) error { +func (h *Outbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } @@ -229,6 +229,7 @@ func (h *Outbound) Start(stage adapter.StartStage) error { if err != nil { return err } + scope.Add(h.client.Close) h.logger.Info("NaiveProxy started, version: ", h.client.Engine().Version()) return nil } @@ -260,10 +261,6 @@ func (h *Outbound) InterfaceUpdated(ctx context.Context) { h.client.CloseAllConnections() } -func (h *Outbound) Close() error { - return h.client.Close() -} - func (h *Outbound) Client() *cronet.NaiveClient { return h.client } diff --git a/protocol/openconnect/client.go b/protocol/openconnect/client.go index 16216361..2bb9a3d7 100644 --- a/protocol/openconnect/client.go +++ b/protocol/openconnect/client.go @@ -43,25 +43,22 @@ var ( type Endpoint struct { endpointBase - loopContext context.Context - cancelLoop context.CancelFunc - dnsRouter adapter.DNSRouter - client *openconnect.Client - device openconnecttransport.Device - server string - flavor string - stateAccess sync.Mutex - state atomic.Pointer[clientState] - dnsTransportAccess sync.Mutex - dnsTransport *DNSTransport - deviceStarted bool - readLoopDone chan struct{} - statusAccess sync.Mutex - statusUpdated chan struct{} - terminalError string - authFormLoopDone chan struct{} - activeTransportLoopDone chan struct{} - hotpCounter atomic.Uint64 + loopContext context.Context + cancelLoop context.CancelFunc + dnsRouter adapter.DNSRouter + client *openconnect.Client + device openconnecttransport.Device + server string + flavor string + stateAccess sync.Mutex + state atomic.Pointer[clientState] + dnsTransportAccess sync.Mutex + dnsTransport *DNSTransport + deviceStarted bool + statusAccess sync.Mutex + statusUpdated chan struct{} + terminalError string + hotpCounter atomic.Uint64 } type clientState struct { @@ -418,34 +415,60 @@ func (e *Endpoint) updateState(update func(state *clientState)) { e.state.Store(&newState) } -func (e *Endpoint) Start(stage adapter.StartStage) error { - if stage != adapter.StartStatePostStart { - return nil +func (e *Endpoint) Start(stage adapter.StartStage, scope *adapter.Scope) error { + switch stage { + case adapter.StartStateInitialize: + scope.Add(func() error { + e.notifyStatusUpdated() + return nil + }) + scope.Add(e.device.Close) + scope.Add(func() error { + e.cancelLoop() + return nil + }) + case adapter.StartStatePostStart: + var loopGroup sync.WaitGroup + scope.Add(func() error { + loopGroup.Wait() + return nil + }) + err := e.client.Start() + if err != nil { + return err + } + scope.Add(e.client.Close) + e.stateAccess.Lock() + e.updateState(func(state *clientState) { + state.started = true + }) + e.stateAccess.Unlock() + scope.Add(func() error { + e.stateAccess.Lock() + e.updateState(func(state *clientState) { + state.started = false + }) + e.stateAccess.Unlock() + return nil + }) + loopGroup.Go(func() { + e.readLoop(scope.Context()) + }) + loopGroup.Go(func() { + e.watchAuthForms(scope.Context()) + }) + loopGroup.Go(func() { + e.watchActiveTransport(scope.Context()) + }) } - err := e.client.Start() - if err != nil { - return err - } - e.stateAccess.Lock() - e.updateState(func(state *clientState) { - state.started = true - }) - e.readLoopDone = make(chan struct{}) - e.authFormLoopDone = make(chan struct{}) - e.activeTransportLoopDone = make(chan struct{}) - e.stateAccess.Unlock() - go e.readLoop() - go e.watchAuthForms() - go e.watchActiveTransport() return nil } -func (e *Endpoint) readLoop() { - defer close(e.readLoopDone) +func (e *Endpoint) readLoop(ctx context.Context) { for { - packetBuffers, err := e.client.ReadDataPackets(e.loopContext) + packetBuffers, err := e.client.ReadDataPackets(ctx) if err != nil { - if E.IsClosedOrCanceled(err) || e.loopContext.Err() != nil { + if E.IsClosedOrCanceled(err) || ctx.Err() != nil { return } e.logger.Error(E.Cause(err, "client terminated")) @@ -463,30 +486,6 @@ func (e *Endpoint) readLoop() { } } -func (e *Endpoint) Close() error { - e.stateAccess.Lock() - e.updateState(func(state *clientState) { - state.started = false - }) - readLoopDone := e.readLoopDone - authFormLoopDone := e.authFormLoopDone - activeTransportLoopDone := e.activeTransportLoopDone - e.stateAccess.Unlock() - e.cancelLoop() - err := E.Errors(e.client.Close(), e.device.Close()) - if readLoopDone != nil { - <-readLoopDone - } - if authFormLoopDone != nil { - <-authFormLoopDone - } - if activeTransportLoopDone != nil { - <-activeTransportLoopDone - } - e.notifyStatusUpdated() - return err -} - func (e *Endpoint) InterfaceUpdated(ctx context.Context) { e.client.RestartSession() } diff --git a/protocol/openconnect/dns_transport.go b/protocol/openconnect/dns_transport.go index eb97ff0b..ae70ee23 100644 --- a/protocol/openconnect/dns_transport.go +++ b/protocol/openconnect/dns_transport.go @@ -30,18 +30,19 @@ func RegisterDNSTransport(registry *dns.TransportRegistry) { type DNSTransport struct { dns.TransportAdapter + ctx context.Context logger logger.ContextLogger endpointTag string acceptDefaultResolvers bool acceptSearchDomain bool endpointManager adapter.EndpointManager - endpoint *Endpoint dialer N.Dialer access sync.RWMutex closed bool routes []openConnectDNSRoute searchDomains []string defaultResolvers []adapter.DNSTransport + resolverScope *adapter.Scope } type openConnectDNSRoute struct { @@ -55,6 +56,7 @@ func NewDNSTransport(ctx context.Context, logger log.ContextLogger, tag string, } return &DNSTransport{ TransportAdapter: dns.NewTransportAdapter(C.DNSTypeOpenConnect, tag, nil), + ctx: ctx, logger: logger, endpointTag: options.Endpoint, acceptDefaultResolvers: options.AcceptDefaultResolvers, @@ -63,7 +65,7 @@ func NewDNSTransport(ctx context.Context, logger log.ContextLogger, tag string, }, nil } -func (t *DNSTransport) Start(stage adapter.StartStage) error { +func (t *DNSTransport) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateInitialize { return nil } @@ -75,13 +77,34 @@ func (t *DNSTransport) Start(stage adapter.StartStage) error { if !isOpenConnect { return E.New("endpoint is not OpenConnect: ", t.endpointTag) } + scope.Add(func() error { + t.access.Lock() + resolverScope := t.resolverScope + t.closed = true + t.routes = nil + t.searchDomains = nil + t.defaultResolvers = nil + t.resolverScope = nil + t.access.Unlock() + if resolverScope != nil { + return resolverScope.Close() + } + return nil + }) openConnectEndpoint.dnsTransportAccess.Lock() if openConnectEndpoint.dnsTransport != nil && openConnectEndpoint.dnsTransport.Tag() != t.Tag() { openConnectEndpoint.dnsTransportAccess.Unlock() return E.New("only one DNS server is allowed for an endpoint") } openConnectEndpoint.dnsTransport = t - t.endpoint = openConnectEndpoint + scope.Add(func() error { + openConnectEndpoint.dnsTransportAccess.Lock() + if openConnectEndpoint.dnsTransport == t { + openConnectEndpoint.dnsTransport = nil + } + openConnectEndpoint.dnsTransportAccess.Unlock() + return nil + }) t.dialer = openConnectEndpoint state := openConnectEndpoint.state.Load() if state.started && state.tunnelConfigured && openConnectEndpoint.client.Ready() { @@ -176,32 +199,29 @@ func (t *DNSTransport) updateConfiguration(configuration openconnecttransport.Co defaultResolvers = nil } + resolverScope := adapter.NewScope(t.ctx, t.logger) + for _, resolver := range resolverByAddress { + err := resolver.Start(adapter.StartStateStart, resolverScope) + if err != nil { + resolverScope.Close() + t.logger.Error(E.Cause(err, "start DNS resolver")) + return + } + } t.access.Lock() if t.closed { t.access.Unlock() - for _, resolver := range resolverByAddress { - _ = resolver.Close() - } + resolverScope.Close() return } - oldResolvers := t.collectResolversLocked() + oldResolverScope := t.resolverScope t.routes = routes t.searchDomains = searchDomains t.defaultResolvers = defaultResolvers - activeResolvers := t.collectResolversLocked() + t.resolverScope = resolverScope t.access.Unlock() - - for _, resolver := range oldResolvers { - _ = resolver.Close() - } - activeResolverSet := make(map[adapter.DNSTransport]bool, len(activeResolvers)) - for _, resolver := range activeResolvers { - activeResolverSet[resolver] = true - } - for _, resolver := range resolverByAddress { - if !activeResolverSet[resolver] { - _ = resolver.Close() - } + if oldResolverScope != nil { + oldResolverScope.Close() } if len(resolverByAddress) > 0 { t.logger.Info("updated ", len(routes), " DNS routes and ", len(resolverByAddress), " resolvers") @@ -219,28 +239,6 @@ func (t *DNSTransport) Reset() { } } -func (t *DNSTransport) Close() error { - if t.endpoint != nil { - t.endpoint.dnsTransportAccess.Lock() - if t.endpoint.dnsTransport == t { - t.endpoint.dnsTransport = nil - } - t.endpoint.dnsTransportAccess.Unlock() - } - t.access.Lock() - resolvers := t.collectResolversLocked() - t.closed = true - t.routes = nil - t.searchDomains = nil - t.defaultResolvers = nil - t.access.Unlock() - var closeErr error - for _, resolver := range resolvers { - closeErr = E.Errors(closeErr, resolver.Close()) - } - return closeErr -} - func (t *DNSTransport) PreferredDomain(domain string) bool { canonicalDomain := mDNS.Fqdn(canonicalOpenConnectDomain(domain)) t.access.RLock() diff --git a/protocol/openconnect/status.go b/protocol/openconnect/status.go index b7be1dc5..7eede3a3 100644 --- a/protocol/openconnect/status.go +++ b/protocol/openconnect/status.go @@ -1,6 +1,7 @@ package openconnect import ( + "context" "net/http" "slices" @@ -132,8 +133,7 @@ func (e *Endpoint) setTerminalError(err error) { e.statusAccess.Unlock() } -func (e *Endpoint) watchAuthForms() { - defer close(e.authFormLoopDone) +func (e *Endpoint) watchAuthForms(ctx context.Context) { var loggedAuthChallengeID string for { authChallengeUpdated := e.client.AuthChallengeUpdated() @@ -148,15 +148,14 @@ func (e *Endpoint) watchAuthForms() { } e.notifyStatusUpdated() select { - case <-e.loopContext.Done(): + case <-ctx.Done(): return case <-authChallengeUpdated: } } } -func (e *Endpoint) watchActiveTransport() { - defer close(e.activeTransportLoopDone) +func (e *Endpoint) watchActiveTransport(ctx context.Context) { for { transportUpdated := e.client.ActiveTransportUpdated() transport := e.client.ActiveTransport() @@ -167,7 +166,7 @@ func (e *Endpoint) watchActiveTransport() { e.stateAccess.Unlock() e.notifyStatusUpdated() select { - case <-e.loopContext.Done(): + case <-ctx.Done(): return case <-transportUpdated: } diff --git a/protocol/openvpn/client.go b/protocol/openvpn/client.go index 03f8e07a..a0e585e0 100644 --- a/protocol/openvpn/client.go +++ b/protocol/openvpn/client.go @@ -42,23 +42,21 @@ var ( type ClientEndpoint struct { endpointBase - ctx context.Context - loopContext context.Context - cancelLoop context.CancelFunc - dnsRouter adapter.DNSRouter - outboundDialer N.Dialer - queryOptions adapter.DNSQueryOptions - client *ovpn.Client - device ovpntransport.Device - stateAccess sync.Mutex - state atomic.Pointer[clientState] - dnsTransport *DNSTransport - deviceStarted bool - readLoopDone chan struct{} - statusAccess sync.Mutex - statusUpdated chan struct{} - terminalError string - challengeLoopDone chan struct{} + ctx context.Context + loopContext context.Context + cancelLoop context.CancelFunc + dnsRouter adapter.DNSRouter + outboundDialer N.Dialer + queryOptions adapter.DNSQueryOptions + client *ovpn.Client + device ovpntransport.Device + stateAccess sync.Mutex + state atomic.Pointer[clientState] + dnsTransport *DNSTransport + deviceStarted bool + statusAccess sync.Mutex + statusUpdated chan struct{} + terminalError string } type clientState struct { @@ -596,32 +594,57 @@ func (c *ClientEndpoint) uninstallDNSTransport(dnsTransport *DNSTransport) { c.stateAccess.Unlock() } -func (c *ClientEndpoint) Start(stage adapter.StartStage) error { - if stage != adapter.StartStatePostStart { - return nil +func (c *ClientEndpoint) Start(stage adapter.StartStage, scope *adapter.Scope) error { + switch stage { + case adapter.StartStateInitialize: + scope.Add(func() error { + c.notifyStatusUpdated() + return nil + }) + scope.Add(c.device.Close) + scope.Add(func() error { + c.cancelLoop() + return nil + }) + case adapter.StartStatePostStart: + var loopGroup sync.WaitGroup + scope.Add(func() error { + loopGroup.Wait() + return nil + }) + err := c.client.Start() + if err != nil { + return err + } + scope.Add(c.client.Close) + c.stateAccess.Lock() + c.updateState(func(state *clientState) { + state.started = true + }) + c.stateAccess.Unlock() + scope.Add(func() error { + c.stateAccess.Lock() + c.updateState(func(state *clientState) { + state.started = false + }) + c.stateAccess.Unlock() + return nil + }) + loopGroup.Go(func() { + c.readLoop(scope.Context()) + }) + loopGroup.Go(func() { + c.watchChallenges(scope.Context()) + }) } - err := c.client.Start() - if err != nil { - return err - } - c.stateAccess.Lock() - c.updateState(func(state *clientState) { - state.started = true - }) - c.readLoopDone = make(chan struct{}) - c.challengeLoopDone = make(chan struct{}) - c.stateAccess.Unlock() - go c.readLoop() - go c.watchChallenges() return nil } -func (c *ClientEndpoint) readLoop() { - defer close(c.readLoopDone) +func (c *ClientEndpoint) readLoop(ctx context.Context) { for { - packetBuffers, err := c.client.ReadDataPackets(c.loopContext) + packetBuffers, err := c.client.ReadDataPackets(ctx) if err != nil { - if E.IsClosedOrCanceled(err) || c.loopContext.Err() != nil { + if E.IsClosedOrCanceled(err) || ctx.Err() != nil { return } c.logger.Error(E.Cause(err, "client terminated")) @@ -639,26 +662,6 @@ func (c *ClientEndpoint) readLoop() { } } -func (c *ClientEndpoint) Close() error { - c.stateAccess.Lock() - c.updateState(func(state *clientState) { - state.started = false - }) - readLoopDone := c.readLoopDone - challengeLoopDone := c.challengeLoopDone - c.stateAccess.Unlock() - c.cancelLoop() - err := E.Errors(c.client.Close(), c.device.Close()) - if readLoopDone != nil { - <-readLoopDone - } - if challengeLoopDone != nil { - <-challengeLoopDone - } - c.notifyStatusUpdated() - return err -} - func (c *ClientEndpoint) InterfaceUpdated(ctx context.Context) { c.client.RestartSession() } diff --git a/protocol/openvpn/dns_transport.go b/protocol/openvpn/dns_transport.go index 188da2af..41461979 100644 --- a/protocol/openvpn/dns_transport.go +++ b/protocol/openvpn/dns_transport.go @@ -43,7 +43,6 @@ type DNSTransport struct { acceptDefaultResolvers bool acceptSearchDomain bool endpointManager adapter.EndpointManager - endpoint *ClientEndpoint dialer N.Dialer updateAccess sync.Mutex access sync.RWMutex @@ -51,6 +50,7 @@ type DNSTransport struct { routes map[string][]adapter.DNSTransport searchDomains []string defaultResolvers []adapter.DNSTransport + resolverScope *adapter.Scope } func NewDNSTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.OpenVPNDNSServerOptions) (adapter.DNSTransport, error) { @@ -68,7 +68,7 @@ func NewDNSTransport(ctx context.Context, logger log.ContextLogger, tag string, }, nil } -func (t *DNSTransport) Start(stage adapter.StartStage) error { +func (t *DNSTransport) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateInitialize { return nil } @@ -80,14 +80,31 @@ func (t *DNSTransport) Start(stage adapter.StartStage) error { if !isOpenVPN { return E.New("endpoint is not an OpenVPN client: ", t.endpointTag) } - t.endpoint = endpoint t.dialer = endpoint + scope.Add(func() error { + t.updateAccess.Lock() + t.access.Lock() + resolverScope := t.resolverScope + t.closed = true + t.routes = nil + t.searchDomains = nil + t.defaultResolvers = nil + t.resolverScope = nil + t.access.Unlock() + t.updateAccess.Unlock() + if resolverScope != nil { + return resolverScope.Close() + } + return nil + }) err := endpoint.installDNSTransport(t) if err != nil { - t.endpoint = nil - t.dialer = nil return err } + scope.Add(func() error { + endpoint.uninstallDNSTransport(t) + return nil + }) return nil } @@ -110,7 +127,7 @@ func (t *DNSTransport) updateResolvers(configuration ovpntransport.Configuration routes := make(map[string][]adapter.DNSTransport) searchDomains := normalizeOpenVPNDomains(configuration.SearchDomains) var defaultResolvers []adapter.DNSTransport - var newResolvers []adapter.DNSTransport + resolverScope := adapter.NewScope(t.ctx, t.logger) servers := slices.Clone(configuration.DNSServers) slices.SortFunc(servers, func(left ovpntransport.DNSServer, right ovpntransport.DNSServer) int { return left.Priority - right.Priority @@ -119,18 +136,21 @@ func (t *DNSTransport) updateResolvers(configuration ovpntransport.Configuration if len(servers) > 0 { server := servers[0] if server.DNSSEC == "yes" { - return t.failResolverUpdate(newResolvers, E.New("DNSSEC validation is required but is not supported")) + return t.failResolverUpdate(resolverScope, E.New("DNSSEC validation is required but is not supported")) } for _, address := range server.Addresses { resolver, err := t.createResolver(server, address) if err != nil { - return t.failResolverUpdate(newResolvers, err) + return t.failResolverUpdate(resolverScope, err) + } + err = resolver.Start(adapter.StartStateStart, resolverScope) + if err != nil { + return t.failResolverUpdate(resolverScope, err) } selectedResolvers = append(selectedResolvers, resolver) - newResolvers = append(newResolvers, resolver) } if len(selectedResolvers) == 0 { - return t.failResolverUpdate(newResolvers, E.New("DNS server ", server.Priority, " has no addresses")) + return t.failResolverUpdate(resolverScope, E.New("DNS server ", server.Priority, " has no addresses")) } if len(server.ResolveDomains) == 0 { defaultResolvers = slices.Clone(selectedResolvers) @@ -145,12 +165,15 @@ func (t *DNSTransport) updateResolvers(configuration ovpntransport.Configuration } else { for _, address := range configuration.DNS { resolver := dnsTransport.NewUDPRaw(t.logger, t.TransportAdapter, t.dialer, M.SocksaddrFrom(address, 53)) + err := resolver.Start(adapter.StartStateStart, resolverScope) + if err != nil { + return t.failResolverUpdate(resolverScope, err) + } selectedResolvers = append(selectedResolvers, resolver) - newResolvers = append(newResolvers, resolver) } if len(configuration.DNSRoutes) > 0 { if len(selectedResolvers) == 0 { - return t.failResolverUpdate(newResolvers, E.New("DOMAIN-ROUTE requires traditional pushed DNS servers")) + return t.failResolverUpdate(resolverScope, E.New("DOMAIN-ROUTE requires traditional pushed DNS servers")) } for _, domain := range configuration.DNSRoutes { normalizedDomain := normalizeOpenVPNDomain(domain) @@ -163,32 +186,40 @@ func (t *DNSTransport) updateResolvers(configuration ovpntransport.Configuration } } if len(searchDomains) > 0 && len(selectedResolvers) == 0 { - return t.failResolverUpdate(newResolvers, E.New("search domains require pushed DNS servers")) + return t.failResolverUpdate(resolverScope, E.New("search domains require pushed DNS servers")) } for _, searchDomain := range searchDomains { routes[searchDomain] = slices.Clone(selectedResolvers) } t.access.Lock() - oldResolvers := t.collectResolversLocked() + oldResolverScope := t.resolverScope t.routes = routes t.searchDomains = searchDomains t.defaultResolvers = defaultResolvers + t.resolverScope = resolverScope t.access.Unlock() - closeErr := closeDNSTransports(oldResolvers) + var closeErr error + if oldResolverScope != nil { + closeErr = oldResolverScope.Close() + } t.logger.Info("updated ", len(routes), " DNS routes, ", len(searchDomains), " search domains and ", len(defaultResolvers), " default resolvers") return closeErr } -func (t *DNSTransport) failResolverUpdate(newResolvers []adapter.DNSTransport, updateErr error) error { - newCloseErr := closeDNSTransports(newResolvers) +func (t *DNSTransport) failResolverUpdate(resolverScope *adapter.Scope, updateErr error) error { + newCloseErr := resolverScope.Close() t.access.Lock() - oldResolvers := t.collectResolversLocked() + oldResolverScope := t.resolverScope t.routes = nil t.searchDomains = nil t.defaultResolvers = nil + t.resolverScope = nil t.access.Unlock() - oldCloseErr := closeDNSTransports(oldResolvers) + var oldCloseErr error + if oldResolverScope != nil { + oldCloseErr = oldResolverScope.Close() + } return E.Errors(updateErr, newCloseErr, oldCloseErr) } @@ -255,24 +286,6 @@ func (t *DNSTransport) Reset() { } } -func (t *DNSTransport) Close() error { - if t.endpoint != nil { - t.endpoint.uninstallDNSTransport(t) - } - t.updateAccess.Lock() - t.access.Lock() - resolvers := t.collectResolversLocked() - t.closed = true - t.routes = nil - t.searchDomains = nil - t.defaultResolvers = nil - t.access.Unlock() - t.endpoint = nil - t.dialer = nil - t.updateAccess.Unlock() - return closeDNSTransports(resolvers) -} - func (t *DNSTransport) Raw() bool { return true } @@ -384,16 +397,6 @@ func (t *DNSTransport) collectResolversLocked() []adapter.DNSTransport { return common.Uniq(resolvers) } -func closeDNSTransports(resolvers []adapter.DNSTransport) error { - var err error - for _, resolver := range common.Uniq(resolvers) { - err = E.Append(err, resolver.Close(), func(closeErr error) error { - return E.Cause(closeErr, "close DNS resolver") - }) - } - return err -} - func normalizeOpenVPNDomain(domain string) string { normalized := strings.TrimSpace(strings.ToLower(domain)) if normalized == "." { diff --git a/protocol/openvpn/server.go b/protocol/openvpn/server.go index cd8ccc66..269b7a9e 100644 --- a/protocol/openvpn/server.go +++ b/protocol/openvpn/server.go @@ -6,6 +6,7 @@ import ( "net/netip" "slices" "strconv" + "sync" "sync/atomic" "time" @@ -35,7 +36,6 @@ var ( type ServerEndpoint struct { endpointBase ctx context.Context - loopContext context.Context cancelLoop context.CancelFunc options option.OpenVPNServerEndpointOptions serverOptions ovpn.ServerOptions @@ -45,7 +45,6 @@ type ServerEndpoint struct { device ovpntransport.Device localAddresses []netip.Prefix started atomic.Bool - readLoopDone chan struct{} } type udpEgressPacketConn struct { @@ -80,7 +79,6 @@ func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.Co logger: logger, }, ctx: ctx, - loopContext: loopContext, cancelLoop: cancelLoop, options: options, dnsRouter: service.FromContext[adapter.DNSRouter](ctx), @@ -161,7 +159,15 @@ func validateServerTopology(topology string) error { } } -func (s *ServerEndpoint) Start(stage adapter.StartStage) error { +func (s *ServerEndpoint) Start(stage adapter.StartStage, scope *adapter.Scope) error { + if stage == adapter.StartStateInitialize { + scope.Add(s.device.Close) + scope.Add(func() error { + s.cancelLoop() + return nil + }) + return nil + } if stage != adapter.StartStateStart { return nil } @@ -225,6 +231,7 @@ func (s *ServerEndpoint) Start(stage adapter.StartStage) error { if err != nil { return err } + scope.Add(s.listener.Close) serverOptions := s.serverOptions if streamListener != nil { serverOptions.Transport.ListenAddress = streamListener.Addr().String() @@ -238,26 +245,31 @@ func (s *ServerEndpoint) Start(stage adapter.StartStage) error { if packetConn != nil { _ = packetConn.Close() } - s.listener.Close() return err } s.server = server + var loopGroup sync.WaitGroup + scope.Add(func() error { + loopGroup.Wait() + return nil + }) + scope.Add(server.Close) err = s.device.Start() if err != nil { - s.listener.Close() - server.Close() return err } err = server.Start() if err != nil { - s.device.Close() - s.listener.Close() - server.Close() return err } s.started.Store(true) - s.readLoopDone = make(chan struct{}) - go s.readLoop() + scope.Add(func() error { + s.started.Store(false) + return nil + }) + loopGroup.Go(func() { + s.readLoop(scope.Context()) + }) return nil } @@ -598,12 +610,11 @@ func applyServerPushOptions(serverOptions *ovpn.ServerOptions, options option.Op return nil } -func (s *ServerEndpoint) readLoop() { - defer close(s.readLoopDone) +func (s *ServerEndpoint) readLoop(ctx context.Context) { for { - serverPacketBuffers, err := s.server.ReadDataPackets(s.loopContext) + serverPacketBuffers, err := s.server.ReadDataPackets(ctx) if err != nil { - if E.IsClosedOrCanceled(err) || s.loopContext.Err() != nil { + if E.IsClosedOrCanceled(err) || ctx.Err() != nil { return } s.logger.Error(E.Cause(err, "server terminated")) @@ -622,27 +633,6 @@ func (s *ServerEndpoint) readLoop() { } } -func (s *ServerEndpoint) Close() error { - s.started.Store(false) - s.cancelLoop() - var serverErr error - if s.server != nil { - serverErr = s.server.Close() - } - if s.readLoopDone != nil { - <-s.readLoopDone - } - var deviceErr error - if s.device != nil { - deviceErr = s.device.Close() - } - var listenerErr error - if s.listener != nil { - listenerErr = s.listener.Close() - } - return E.Errors(serverErr, deviceErr, listenerErr) -} - func (s *ServerEndpoint) PreMatchFlow(network string, destination netip.Addr) adapter.PreMatchAction { return adapter.PreMatchFlow } diff --git a/protocol/openvpn/status.go b/protocol/openvpn/status.go index b39a22e4..6e4032ef 100644 --- a/protocol/openvpn/status.go +++ b/protocol/openvpn/status.go @@ -1,6 +1,7 @@ package openvpn import ( + "context" "slices" "github.com/sagernet/sing-box/adapter" @@ -81,8 +82,7 @@ func (c *ClientEndpoint) setTerminalError(err error) { c.statusAccess.Unlock() } -func (c *ClientEndpoint) watchChallenges() { - defer close(c.challengeLoopDone) +func (c *ClientEndpoint) watchChallenges(ctx context.Context) { var loggedChallengeID string for { challengeUpdated := c.client.ChallengeUpdated() @@ -93,7 +93,7 @@ func (c *ClientEndpoint) watchChallenges() { } c.notifyStatusUpdated() select { - case <-c.loopContext.Done(): + case <-ctx.Done(): return case <-challengeUpdated: } diff --git a/protocol/redirect/redirect.go b/protocol/redirect/redirect.go index ff52bd7b..83818999 100644 --- a/protocol/redirect/redirect.go +++ b/protocol/redirect/redirect.go @@ -42,15 +42,16 @@ func NewRedirect(ctx context.Context, router adapter.Router, logger log.ContextL return redirect, nil } -func (h *Redirect) Start(stage adapter.StartStage) error { +func (h *Redirect) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } - return h.listener.Start() -} - -func (h *Redirect) Close() error { - return h.listener.Close() + err := h.listener.Start() + if err != nil { + return err + } + scope.Add(h.listener.Close) + return nil } func (h *Redirect) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { diff --git a/protocol/redirect/tproxy.go b/protocol/redirect/tproxy.go index ab5ba660..cce01c02 100644 --- a/protocol/redirect/tproxy.go +++ b/protocol/redirect/tproxy.go @@ -70,7 +70,7 @@ func NewTProxy(ctx context.Context, router adapter.Router, logger log.ContextLog return tproxy, nil } -func (t *TProxy) Start(stage adapter.StartStage) error { +func (t *TProxy) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } @@ -78,22 +78,19 @@ func (t *TProxy) Start(stage adapter.StartStage) error { if err != nil { return err } + scope.Add(t.udpNat.Close) err = t.listener.Start() if err != nil { - _ = t.udpNat.Close() + return err } - return err + scope.Add(t.listener.Close) + return nil } func (t *TProxy) InterfaceUpdated(ctx context.Context) { t.udpNat.Purge() } -func (t *TProxy) Close() error { - _ = t.udpNat.Close() - return t.listener.Close() -} - func (t *TProxy) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { metadata.Inbound = t.Tag() metadata.InboundType = t.Type() diff --git a/protocol/shadowsocks/inbound.go b/protocol/shadowsocks/inbound.go index 3fc9dc74..1ae1b5a5 100644 --- a/protocol/shadowsocks/inbound.go +++ b/protocol/shadowsocks/inbound.go @@ -95,15 +95,16 @@ func newInbound(ctx context.Context, router adapter.Router, logger log.ContextLo return inbound, err } -func (h *Inbound) Start(stage adapter.StartStage) error { +func (h *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } - return h.listener.Start() -} - -func (h *Inbound) Close() error { - return h.listener.Close() + err := h.listener.Start() + if err != nil { + return err + } + scope.Add(h.listener.Close) + return nil } //nolint:staticcheck diff --git a/protocol/shadowsocks/inbound_multi.go b/protocol/shadowsocks/inbound_multi.go index 9233c749..8742e417 100644 --- a/protocol/shadowsocks/inbound_multi.go +++ b/protocol/shadowsocks/inbound_multi.go @@ -109,15 +109,16 @@ func newMultiInbound(ctx context.Context, router adapter.Router, logger log.Cont return inbound, err } -func (h *MultiInbound) Start(stage adapter.StartStage) error { +func (h *MultiInbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } - return h.listener.Start() -} - -func (h *MultiInbound) Close() error { - return h.listener.Close() + err := h.listener.Start() + if err != nil { + return err + } + scope.Add(h.listener.Close) + return nil } func (h *MultiInbound) SetTracker(tracker adapter.SSMTracker) { diff --git a/protocol/shadowsocks/inbound_relay.go b/protocol/shadowsocks/inbound_relay.go index c79eeb15..7945b901 100644 --- a/protocol/shadowsocks/inbound_relay.go +++ b/protocol/shadowsocks/inbound_relay.go @@ -86,15 +86,16 @@ func newRelayInbound(ctx context.Context, router adapter.Router, logger log.Cont return inbound, err } -func (h *RelayInbound) Start(stage adapter.StartStage) error { +func (h *RelayInbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } - return h.listener.Start() -} - -func (h *RelayInbound) Close() error { - return h.listener.Close() + err := h.listener.Start() + if err != nil { + return err + } + scope.Add(h.listener.Close) + return nil } //nolint:staticcheck diff --git a/protocol/shadowsocks/outbound.go b/protocol/shadowsocks/outbound.go index 2ee20031..08f16c18 100644 --- a/protocol/shadowsocks/outbound.go +++ b/protocol/shadowsocks/outbound.go @@ -152,8 +152,14 @@ func (h *Outbound) CloseIdleConnections() { } } -func (h *Outbound) Close() error { - return common.Close(common.PtrOrNil(h.multiplexDialer)) +func (h *Outbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { + if stage != adapter.StartStateInitialize { + return nil + } + if h.multiplexDialer != nil { + scope.Add(h.multiplexDialer.Close) + } + return nil } var _ N.Dialer = (*shadowsocksDialer)(nil) diff --git a/protocol/shadowtls/inbound.go b/protocol/shadowtls/inbound.go index 5781a11c..7dd4524b 100644 --- a/protocol/shadowtls/inbound.go +++ b/protocol/shadowtls/inbound.go @@ -104,15 +104,16 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo return inbound, nil } -func (h *Inbound) Start(stage adapter.StartStage) error { +func (h *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } - return h.listener.Start() -} - -func (h *Inbound) Close() error { - return h.listener.Close() + err := h.listener.Start() + if err != nil { + return err + } + scope.Add(h.listener.Close) + return nil } func (h *Inbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { diff --git a/protocol/snell/inbound.go b/protocol/snell/inbound.go index 4ef07df1..2d930074 100644 --- a/protocol/snell/inbound.go +++ b/protocol/snell/inbound.go @@ -119,15 +119,16 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo return inbound, nil } -func (h *Inbound) Start(stage adapter.StartStage) error { +func (h *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } - return h.listener.Start() -} - -func (h *Inbound) Close() error { - return h.listener.Close() + err := h.listener.Start() + if err != nil { + return err + } + scope.Add(h.listener.Close) + return nil } func (h *Inbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { diff --git a/protocol/snell/outbound.go b/protocol/snell/outbound.go index 71ab1219..eb41c362 100644 --- a/protocol/snell/outbound.go +++ b/protocol/snell/outbound.go @@ -163,6 +163,10 @@ func (h *Outbound) CloseIdleConnections() { h.client.CloseIdleConnections() } -func (h *Outbound) Close() error { - return h.client.Close() +func (h *Outbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { + if stage != adapter.StartStateInitialize { + return nil + } + scope.Add(h.client.Close) + return nil } diff --git a/protocol/socks/inbound.go b/protocol/socks/inbound.go index c498efc4..b37fa9c9 100644 --- a/protocol/socks/inbound.go +++ b/protocol/socks/inbound.go @@ -60,15 +60,16 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo return inbound, nil } -func (h *Inbound) Start(stage adapter.StartStage) error { +func (h *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } - return h.listener.Start() -} - -func (h *Inbound) Close() error { - return h.listener.Close() + err := h.listener.Start() + if err != nil { + return err + } + scope.Add(h.listener.Close) + return nil } func (h *Inbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { diff --git a/protocol/ssh/outbound.go b/protocol/ssh/outbound.go index 7d82a3e1..7e9aa0ac 100644 --- a/protocol/ssh/outbound.go +++ b/protocol/ssh/outbound.go @@ -260,8 +260,14 @@ func (s *Outbound) releaseStream(client *ssh.Client, keepSession bool) { common.Close(clientConn) } -func (s *Outbound) Close() error { - return common.Close(s.clientConn) +func (s *Outbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { + if stage != adapter.StartStateInitialize { + return nil + } + scope.Add(func() error { + return common.Close(s.clientConn) + }) + return nil } func (s *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { diff --git a/protocol/tailscale/certificate_provider.go b/protocol/tailscale/certificate_provider.go index 8170943b..30db9593 100644 --- a/protocol/tailscale/certificate_provider.go +++ b/protocol/tailscale/certificate_provider.go @@ -63,7 +63,7 @@ func NewCertificateProvider(ctx context.Context, _ log.ContextLogger, tag string }, nil } -func (p *CertificateProvider) Start(stage adapter.StartStage) error { +func (p *CertificateProvider) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } @@ -82,10 +82,6 @@ func (p *CertificateProvider) Start(stage adapter.StartStage) error { return nil } -func (p *CertificateProvider) Close() error { - return nil -} - func (p *CertificateProvider) GetCertificate(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) { localClient := p.localClient if localClient == nil { diff --git a/protocol/tailscale/dns_transport.go b/protocol/tailscale/dns_transport.go index fecbf702..ba4f42f8 100644 --- a/protocol/tailscale/dns_transport.go +++ b/protocol/tailscale/dns_transport.go @@ -59,6 +59,7 @@ type DNSTransport struct { magicHosts nDNSResolver.MagicDNSHosts searchDomains []string defaultResolvers []adapter.DNSTransport + resolverScope *adapter.Scope } func NewDNSTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.TailscaleDNSServerOptions) (adapter.DNSTransport, error) { @@ -77,7 +78,7 @@ func NewDNSTransport(ctx context.Context, logger log.ContextLogger, tag string, }, nil } -func (t *DNSTransport) Start(stage adapter.StartStage) error { +func (t *DNSTransport) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateInitialize { return nil } @@ -94,6 +95,21 @@ func (t *DNSTransport) Start(stage adapter.StartStage) error { } ep.onReconfigHook = t.onReconfig t.endpoint = ep + scope.Add(func() error { + t.access.Lock() + resolverScope := t.resolverScope + t.routePrefixes = nil + t.routes = nil + t.hosts = nil + t.magicHosts = nil + t.defaultResolvers = nil + t.resolverScope = nil + t.access.Unlock() + if resolverScope != nil { + return resolverScope.Close() + } + return nil + }) return nil } @@ -119,13 +135,14 @@ func (t *DNSTransport) updateDNSServers(routeConfig *router.Config, dnsConfig *n directDialer := common.Must1(dialer.NewDefault(t.ctx, option.DialerOptions{})) return &DNSDialer{transport: t, fallbackDialer: directDialer} }) + resolverScope := adapter.NewScope(t.ctx, t.logger) routes := make(map[string][]adapter.DNSTransport) for domain, resolvers := range dnsConfig.Routes { var myResolvers []adapter.DNSTransport for _, resolver := range resolvers { - myResolver, err := t.createResolver(directDialerOnce, resolver) + myResolver, err := t.createResolver(resolverScope, directDialerOnce, resolver) if err != nil { - return err + return E.Errors(err, resolverScope.Close()) } myResolvers = append(myResolvers, myResolver) } @@ -140,25 +157,26 @@ func (t *DNSTransport) updateDNSServers(routeConfig *router.Config, dnsConfig *n }) var defaultResolvers []adapter.DNSTransport for _, resolver := range dnsConfig.DefaultResolvers { - myResolver, err := t.createResolver(directDialerOnce, resolver) + myResolver, err := t.createResolver(resolverScope, directDialerOnce, resolver) if err != nil { - return err + return E.Errors(err, resolverScope.Close()) } defaultResolvers = append(defaultResolvers, myResolver) } t.access.Lock() - oldResolvers := t.collectResolversLocked() + oldResolverScope := t.resolverScope t.routePrefixes = routePrefixes t.routes = routes t.hosts = hosts t.magicHosts = t.endpoint.server.ExportLocalBackend().ExportMagicDNSHosts() t.searchDomains = searchDomains t.defaultResolvers = defaultResolvers + t.resolverScope = resolverScope t.access.Unlock() - for _, transport := range oldResolvers { - transport.Close() + if oldResolverScope != nil { + oldResolverScope.Close() } if len(defaultResolvers) > 0 { @@ -170,7 +188,7 @@ func (t *DNSTransport) updateDNSServers(routeConfig *router.Config, dnsConfig *n return nil } -func (t *DNSTransport) createResolver(directDialer func() N.Dialer, resolver *dnstype.Resolver) (adapter.DNSTransport, error) { +func (t *DNSTransport) createResolver(resolverScope *adapter.Scope, directDialer func() N.Dialer, resolver *dnstype.Resolver) (adapter.DNSTransport, error) { serverURL, parseURLErr := url.Parse(resolver.Addr) isHTTPScheme := parseURLErr == nil && (serverURL.Scheme == "http" || serverURL.Scheme == "https") var myDialer N.Dialer @@ -181,6 +199,10 @@ func (t *DNSTransport) createResolver(directDialer func() N.Dialer, resolver *dn } if len(resolver.BootstrapResolution) > 0 { bootstrapTransport := transport.NewUDPRaw(t.logger, t.TransportAdapter, myDialer, M.SocksaddrFrom(resolver.BootstrapResolution[0], 53)) + err := bootstrapTransport.Start(adapter.StartStateStart, resolverScope) + if err != nil { + return nil, err + } myDialer = dialer.NewResolveDialer(t.ctx, myDialer, false, "", adapter.DNSQueryOptions{Transport: bootstrapTransport}, 0) } else { myDialer = dialer.NewResolveDialer(t.ctx, myDialer, false, "", t.endpoint.queryOptions, 0) @@ -202,12 +224,22 @@ func (t *DNSTransport) createResolver(directDialer func() N.Dialer, resolver *dn if err != nil { return nil, E.Cause(err, "create TLS config for resolver ", resolver.Addr) } - return transport.NewHTTPSRaw(t.TransportAdapter, t.logger, myDialer, serverURL, http.Header{}, serverAddr, tlsConfig), nil + httpsTransport := transport.NewHTTPSRaw(t.TransportAdapter, t.logger, myDialer, serverURL, http.Header{}, serverAddr, tlsConfig) + err = httpsTransport.Start(adapter.StartStateStart, resolverScope) + if err != nil { + return nil, err + } + return httpsTransport, nil case "http": if serverAddr.Port == 0 { serverAddr.Port = 80 } - return transport.NewHTTPSRaw(t.TransportAdapter, t.logger, myDialer, serverURL, http.Header{}, serverAddr, nil), nil + httpTransport := transport.NewHTTPSRaw(t.TransportAdapter, t.logger, myDialer, serverURL, http.Header{}, serverAddr, nil) + err := httpTransport.Start(adapter.StartStateStart, resolverScope) + if err != nil { + return nil, err + } + return httpTransport, nil } } serverAddr := M.ParseSocksaddr(resolver.Addr) @@ -220,7 +252,12 @@ func (t *DNSTransport) createResolver(directDialer func() N.Dialer, resolver *dn if serverAddr.Port == 0 { serverAddr.Port = 53 } - return transport.NewUDPRaw(t.logger, t.TransportAdapter, myDialer, serverAddr), nil + udpTransport := transport.NewUDPRaw(t.logger, t.TransportAdapter, myDialer, serverAddr) + err := udpTransport.Start(adapter.StartStateStart, resolverScope) + if err != nil { + return nil, err + } + return udpTransport, nil } func buildRoutePrefixes(routeConfig *router.Config) []netip.Prefix { @@ -244,26 +281,6 @@ func buildRoutePrefixes(routeConfig *router.Config) []netip.Prefix { return ipSet.Prefixes() } -func (t *DNSTransport) Close() error { - t.access.Lock() - transports := t.collectResolversLocked() - t.routePrefixes = nil - t.routes = nil - t.hosts = nil - t.magicHosts = nil - t.defaultResolvers = nil - t.access.Unlock() - - var err error - for _, transport := range transports { - name := "resolver/" + transport.Type() + "[" + transport.Tag() + "]" - err = E.Append(err, transport.Close(), func(err error) error { - return E.Cause(err, "close ", name) - }) - } - return err -} - func (t *DNSTransport) Raw() bool { return true } diff --git a/protocol/tailscale/endpoint.go b/protocol/tailscale/endpoint.go index 277a2394..b7b6b90e 100644 --- a/protocol/tailscale/endpoint.go +++ b/protocol/tailscale/endpoint.go @@ -48,7 +48,6 @@ import ( tailscaleroot "github.com/sagernet/tailscale" _ "github.com/sagernet/tailscale/feature/relayserver" "github.com/sagernet/tailscale/ipn" - "github.com/sagernet/tailscale/ipn/ipnlocal" tsDNS "github.com/sagernet/tailscale/net/dns" "github.com/sagernet/tailscale/net/netmon" "github.com/sagernet/tailscale/net/netns" @@ -93,7 +92,6 @@ type Endpoint struct { detour string server *tsnet.Server stack *stack.Stack - icmpForwarder *tun.ICMPForwarder returnAccess sync.Mutex returnPath tun.Return wgEngine wgengine.ExportedUserspaceEngine @@ -122,17 +120,13 @@ type Endpoint struct { sshServerInstance *tailssh.Server sshServerOptions *option.TailscaleSSHServerOptions taildrop *taildropManager - localBackend *ipnlocal.LocalBackend systemInterface bool systemInterfaceName string systemInterfaceMTU uint32 keyAuth bool - serverStarted bool started atomic.Bool - systemTun tun.Tun systemDialer *dialer.DefaultDialer - fallbackTCPCloser func() } func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TailscaleEndpointOptions) (adapter.Endpoint, error) { @@ -261,7 +255,7 @@ func (t *Endpoint) References() []string { return []string{t.detour} } -func (t *Endpoint) Start(stage adapter.StartStage) error { +func (t *Endpoint) Start(stage adapter.StartStage, scope *adapter.Scope) error { switch stage { case adapter.StartStateInitialize: mkdirErr := filemanager.MkdirAll(t.ctx, t.server.Dir, 0o700) @@ -276,14 +270,14 @@ func (t *Endpoint) Start(stage adapter.StartStage) error { } t.server.PeerDNSQueryHandler = (*peerDNSQueryHandler)(t) case adapter.StartStateStart: - return t.start() + return t.start(scope) case adapter.StartStatePostStart: - return t.postStart() + return t.postStart(scope) } return nil } -func (t *Endpoint) start() error { +func (t *Endpoint) start(scope *adapter.Scope) error { if t.platformInterface != nil && t.platformInterface.UsePlatformNetworkInterfaces() { err := t.network.UpdateInterfaces() if err != nil { @@ -308,6 +302,10 @@ func (t *Endpoint) start() error { } }), nil }) + scope.Add(func() error { + netmon.RegisterInterfaceGetter(nil) + return nil + }) } if t.systemInterface { mtu := t.systemInterfaceMTU @@ -333,14 +331,13 @@ func (t *Endpoint) start() error { if err != nil { return err } + scope.Add(systemTun.Close) err = systemTun.Start() if err != nil { - _ = systemTun.Close() return err } wgTunDevice, err := newTunDeviceAdapter(systemTun, int(mtu), t.logger) if err != nil { - _ = systemTun.Close() return err } systemDialer, err := dialer.NewDefault(t.ctx, option.DialerOptions{ @@ -349,15 +346,17 @@ func (t *Endpoint) start() error { }, }) if err != nil { - _ = systemTun.Close() return err } - t.systemTun = systemTun t.systemDialer = systemDialer t.server.Tun = wgTunDevice } if t.network.AutoRedirectOutputMark() != 0 { netns.SetControlFunc(t.network.AutoRedirectOutputMarkFunc()) + scope.Add(func() error { + netns.SetControlFunc(nil) + return nil + }) } else if t.platformInterface != nil && t.platformInterface.UsePlatformNetworkInterfaces() { if t.platformInterface.UsePlatformAutoDetectInterfaceControl() { netns.SetControlFunc(func(network, address string, conn syscall.RawConn) error { @@ -365,6 +364,10 @@ func (t *Endpoint) start() error { return t.platformInterface.AutoDetectInterfaceControl(int(fileDescriptor)) }) }) + scope.Add(func() error { + netns.SetControlFunc(nil) + return nil + }) } else { // NEPacketTunnelProvider sockets are excluded from tunnel routes by // NECP; the empty override only suppresses tailscale's own @@ -372,12 +375,21 @@ func (t *Endpoint) start() error { netns.SetControlFunc(func(string, string, syscall.RawConn) error { return nil }) + scope.Add(func() error { + netns.SetControlFunc(nil) + return nil + }) } } else { bindFunc := t.network.AutoDetectInterfaceFunc() if bindFunc != nil { netns.SetControlFunc(bindFunc) netns.SetListenPacketFunc(t.listenPacket) + scope.Add(func() error { + netns.SetControlFunc(nil) + netns.SetListenPacketFunc(nil) + return nil + }) } } return nil @@ -408,29 +420,35 @@ func (t *Endpoint) listenPacket(ctx context.Context, network string, address str return tun.NewUDPEgressConn(udpConn, egressPool), nil } -func (t *Endpoint) postStart() error { +func (t *Endpoint) postStart(scope *adapter.Scope) error { err := t.server.Start() if err != nil { - if t.systemTun != nil { - _ = t.systemTun.Close() - } return err } - t.serverStarted = true - if t.fallbackTCPCloser == nil { - t.fallbackTCPCloser = t.server.RegisterFallbackTCPHandler(func(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) { - return func(conn net.Conn) { - ctx := log.ContextWithNewID(t.ctx) - source := M.SocksaddrFrom(src.Addr(), src.Port()) - destination := M.SocksaddrFrom(dst.Addr(), dst.Port()) - t.NewConnectionEx(ctx, conn, source, destination, nil) - }, true - }) - } + scope.Add(t.server.Close) + fallbackTCPCloser := t.server.RegisterFallbackTCPHandler(func(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) { + return func(conn net.Conn) { + ctx := log.ContextWithNewID(t.ctx) + source := M.SocksaddrFrom(src.Addr(), src.Port()) + destination := M.SocksaddrFrom(dst.Addr(), dst.Port()) + t.NewConnectionEx(ctx, conn, source, destination, nil) + }, true + }) + scope.Add(func() error { + fallbackTCPCloser() + return nil + }) localBackend := t.server.ExportLocalBackend() - t.localBackend = localBackend + scope.Add(func() error { + t.taildrop.close() + return nil + }) if !version.IsAppleTV() { registerTaildropEndpoint(localBackend, t) + scope.Add(func() error { + unregisterTaildropEndpoint(localBackend) + return nil + }) go t.taildrop.start() } wgEngine := localBackend.ExportEngine().(wgengine.ExportedUserspaceEngine) @@ -447,10 +465,10 @@ func (t *Endpoint) postStart() error { return gonet.TranslateNetstackError(gErr) } icmpForwarder := tun.NewICMPForwarder(ipStack, t, t.logger) + scope.Add(icmpForwarder.Close) ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket) ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket) t.stack = ipStack - t.icmpForwarder = icmpForwarder netstack := t.server.ExportNetstack() if netstack != nil { previousTCP := netstack.GetTCPHandlerForFlow @@ -510,15 +528,20 @@ func (t *Endpoint) postStart() error { if err != nil { return E.Cause(err, "start SSH server") } + scope.Add(sshServer.Close) t.sshReconfigHook = sshServer.OnReconfig t.sshServerInstance = sshServer } - go t.watchState() + go t.watchState(scope.Context()) t.started.Store(true) + scope.Add(func() error { + t.started.Store(false) + return nil + }) return nil } -func (t *Endpoint) watchState() { +func (t *Endpoint) watchState(ctx context.Context) { localBackend := t.server.ExportLocalBackend() var reportedAuthURL string exitNodePending := t.exitNode != "" @@ -533,7 +556,7 @@ func (t *Endpoint) watchState() { } for { var busError string - localBackend.WatchNotifications(t.ctx, ipn.NotifyInitialState|ipn.NotifyPeerPatches, nil, func(roNotify *ipn.Notify) (keepGoing bool) { + localBackend.WatchNotifications(ctx, ipn.NotifyInitialState|ipn.NotifyPeerPatches, nil, func(roNotify *ipn.Notify) (keepGoing bool) { if roNotify.ErrMessage != nil { busError = *roNotify.ErrMessage return false @@ -578,7 +601,7 @@ func (t *Endpoint) watchState() { } return true }) - if t.ctx.Err() != nil { + if ctx.Err() != nil { return } if busError != "" { @@ -587,7 +610,7 @@ func (t *Endpoint) watchState() { t.logger.Warn("state watcher stopped unexpectedly, restarting") } select { - case <-t.ctx.Done(): + case <-ctx.Done(): return case <-time.After(time.Second): } @@ -719,38 +742,6 @@ func (t *Endpoint) Logout(ctx context.Context) error { return nil } -func (t *Endpoint) Close() error { - var err error - t.started.Store(false) - if t.localBackend != nil { - unregisterTaildropEndpoint(t.localBackend) - t.localBackend = nil - } - t.taildrop.close() - if t.icmpForwarder != nil { - t.icmpForwarder.Close() - t.icmpForwarder = nil - } - common.Close(common.PtrOrNil(t.sshServerInstance)) - t.sshServerInstance = nil - if t.serverStarted { - err = common.Close(common.PtrOrNil(t.server)) - t.serverStarted = false - } - netmon.RegisterInterfaceGetter(nil) - netns.SetControlFunc(nil) - netns.SetListenPacketFunc(nil) - if t.fallbackTCPCloser != nil { - t.fallbackTCPCloser() - t.fallbackTCPCloser = nil - } - if t.systemTun != nil { - t.systemTun.Close() - t.systemTun = nil - } - return err -} - func (t *Endpoint) InterfaceUpdated(ctx context.Context) { if !t.started.Load() { return diff --git a/protocol/tor/outbound.go b/protocol/tor/outbound.go index 3220f8cf..00156e20 100644 --- a/protocol/tor/outbound.go +++ b/protocol/tor/outbound.go @@ -40,7 +40,6 @@ type Outbound struct { startConf *tor.StartConf options map[string]string events chan control.Event - instance *tor.Tor socksClient *socks.Client } @@ -96,7 +95,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL }, nil } -func (t *Outbound) Start(stage adapter.StartStage) error { +func (t *Outbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { switch stage { case adapter.StartStateInitialize: if t.startConf.DataDir == "" { @@ -127,11 +126,8 @@ func (t *Outbound) Start(stage adapter.StartStage) error { if err != nil { return err } - err = t.start() - if err != nil { - t.Close() - return err - } + scope.Add(proxy.Close) + return t.start(scope) } return nil } @@ -144,13 +140,17 @@ var torLogEvents = []control.EventCode{ control.EventCodeLogWarn, } -func (t *Outbound) start() error { +func (t *Outbound) start(scope *adapter.Scope) error { + t.events = make(chan control.Event, 8) + scope.Add(func() error { + close(t.events) + return nil + }) torInstance, err := tor.Start(t.ctx, t.startConf) if err != nil { return E.New(strings.ToLower(err.Error())) } - t.instance = torInstance - t.events = make(chan control.Event, 8) + scope.Add(torInstance.Close) err = torInstance.Control.AddEventListener(t.events, torLogEvents...) if err != nil { return err @@ -225,18 +225,6 @@ func (t *Outbound) recvLoop() { } } -func (t *Outbound) Close() error { - err := common.Close( - common.PtrOrNil(t.proxy), - common.PtrOrNil(t.instance), - ) - if t.events != nil { - close(t.events) - t.events = nil - } - return err -} - func (t *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { t.logger.InfoContext(ctx, "outbound connection to ", destination) return t.socksClient.DialContext(ctx, network, destination) diff --git a/protocol/trojan/inbound.go b/protocol/trojan/inbound.go index 87a84754..525dc87e 100644 --- a/protocol/trojan/inbound.go +++ b/protocol/trojan/inbound.go @@ -120,7 +120,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo return inbound, nil } -func (h *Inbound) Start(stage adapter.StartStage) error { +func (h *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } @@ -129,10 +129,18 @@ func (h *Inbound) Start(stage adapter.StartStage) error { if err != nil { return E.Cause(err, "create TLS config") } + scope.Add(h.tlsConfig.Close) } if h.transport == nil { - return h.listener.Start() + err := h.listener.Start() + if err != nil { + return err + } + scope.Add(h.listener.Close) + return nil } + scope.Add(h.transport.Close) + scope.Add(h.listener.Close) if common.Contains(h.transport.Network(), N.NetworkTCP) { tcpListener, err := h.listener.ListenTCP() if err != nil { @@ -160,14 +168,6 @@ func (h *Inbound) Start(stage adapter.StartStage) error { return nil } -func (h *Inbound) Close() error { - return common.Close( - h.listener, - h.tlsConfig, - h.transport, - ) -} - func (h *Inbound) NewConnection(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/trojan/outbound.go b/protocol/trojan/outbound.go index 2273c4b5..d1cd853a 100644 --- a/protocol/trojan/outbound.go +++ b/protocol/trojan/outbound.go @@ -150,8 +150,17 @@ func (h *Outbound) CloseIdleConnections() { } } -func (h *Outbound) Close() error { - return common.Close(common.PtrOrNil(h.multiplexDialer), h.transport) +func (h *Outbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { + if stage != adapter.StartStateInitialize { + return nil + } + if h.transport != nil { + scope.Add(h.transport.Close) + } + if h.multiplexDialer != nil { + scope.Add(h.multiplexDialer.Close) + } + return nil } type trojanDialer Outbound diff --git a/protocol/tuic/inbound.go b/protocol/tuic/inbound.go index 11c38efb..eec5bcb9 100644 --- a/protocol/tuic/inbound.go +++ b/protocol/tuic/inbound.go @@ -153,7 +153,7 @@ func (h *Inbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) } -func (h *Inbound) Start(stage adapter.StartStage) error { +func (h *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } @@ -162,18 +162,17 @@ func (h *Inbound) Start(stage adapter.StartStage) error { if err != nil { return err } + scope.Add(h.tlsConfig.Close) } packetConn, err := h.listener.ListenUDP() if err != nil { return err } - return h.server.Start(packetConn) -} - -func (h *Inbound) Close() error { - return common.Close( - h.listener, - h.tlsConfig, - common.PtrOrNil(h.server), - ) + scope.Add(h.listener.Close) + err = h.server.Start(packetConn) + if err != nil { + return err + } + scope.Add(h.server.Close) + return nil } diff --git a/protocol/tuic/outbound.go b/protocol/tuic/outbound.go index db7a5658..988f0065 100644 --- a/protocol/tuic/outbound.go +++ b/protocol/tuic/outbound.go @@ -161,6 +161,12 @@ func (h *Outbound) CloseIdleConnections() { h.client.CloseIdleConnections() } -func (h *Outbound) Close() error { - return h.client.CloseWithError(os.ErrClosed) +func (h *Outbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { + if stage != adapter.StartStateInitialize { + return nil + } + scope.Add(func() error { + return h.client.CloseWithError(os.ErrClosed) + }) + return nil } diff --git a/protocol/tun/inbound.go b/protocol/tun/inbound.go index 719edd81..9347a96e 100644 --- a/protocol/tun/inbound.go +++ b/protocol/tun/inbound.go @@ -25,7 +25,6 @@ import ( M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/common/ranges" - "github.com/sagernet/sing/common/x/list" "github.com/sagernet/sing/service" "go4.org/netipx" @@ -36,29 +35,27 @@ func RegisterInbound(registry *inbound.Registry) { } type Inbound struct { - tag string - ctx context.Context - router adapter.Router - networkManager adapter.NetworkManager - logger log.ContextLogger - tunOptions tun.Options - udpTimeout time.Duration - udpMapping tun.NATMapping - udpFiltering tun.NATFiltering - udpNATMax uint32 - dnsHijackAddress []netip.Addr - stack string - tunIf tun.Tun - tunStack tun.Stack - platformInterface adapter.PlatformInterface - platformOptions option.TunPlatformOptions - autoRedirect tun.AutoRedirect - routeRuleSet []adapter.RuleSet - routeRuleSetCallback []*list.Element[adapter.RuleSetUpdateCallback] - routeExcludeRuleSet []adapter.RuleSet - routeExcludeRuleSetCallback []*list.Element[adapter.RuleSetUpdateCallback] - routeAddressSet []*netipx.IPSet - routeExcludeAddressSet []*netipx.IPSet + tag string + ctx context.Context + router adapter.Router + networkManager adapter.NetworkManager + logger log.ContextLogger + tunOptions tun.Options + udpTimeout time.Duration + udpMapping tun.NATMapping + udpFiltering tun.NATFiltering + udpNATMax uint32 + dnsHijackAddress []netip.Addr + stack string + tunIf tun.Tun + tunStack tun.Stack + platformInterface adapter.PlatformInterface + platformOptions option.TunPlatformOptions + autoRedirect tun.AutoRedirect + routeRuleSet []adapter.RuleSet + routeExcludeRuleSet []adapter.RuleSet + routeAddressSet []*netipx.IPSet + routeExcludeAddressSet []*netipx.IPSet } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TunInboundOptions) (adapter.Inbound, error) { @@ -328,9 +325,12 @@ func (t *Inbound) Tag() string { return t.tag } -func (t *Inbound) Start(stage adapter.StartStage) error { +func (t *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { switch stage { case adapter.StartStateInitialize: + if t.autoRedirect != nil { + scope.Add(t.autoRedirect.Close) + } if t.tunOptions.DNSModeOrDefault() != tun.DNSModeDisabled && len(t.tunOptions.DNSAddress) == 0 { inet4DNSAddress, _ := t.tunOptions.Inet4DNSAddress() inet6DNSAddress, _ := t.tunOptions.Inet6DNSAddress() @@ -383,7 +383,11 @@ func (t *Inbound) Start(stage adapter.StartStage) error { routeRuleSet.IncRef() t.routeAddressSet = append(t.routeAddressSet, ipSets...) if t.autoRedirect != nil { - t.routeRuleSetCallback = append(t.routeRuleSetCallback, routeRuleSet.RegisterCallback(t.updateRouteAddressSet)) + callback := routeRuleSet.RegisterCallback(t.updateRouteAddressSet) + scope.Add(func() error { + routeRuleSet.UnregisterCallback(callback) + return nil + }) } } for _, routeExcludeRuleSet := range t.routeExcludeRuleSet { @@ -394,7 +398,11 @@ func (t *Inbound) Start(stage adapter.StartStage) error { routeExcludeRuleSet.IncRef() t.routeExcludeAddressSet = append(t.routeExcludeAddressSet, ipSets...) if t.autoRedirect != nil { - t.routeExcludeRuleSetCallback = append(t.routeExcludeRuleSetCallback, routeExcludeRuleSet.RegisterCallback(t.updateRouteAddressSet)) + callback := routeExcludeRuleSet.RegisterCallback(t.updateRouteAddressSet) + scope.Add(func() error { + routeExcludeRuleSet.UnregisterCallback(callback) + return nil + }) } } } @@ -435,14 +443,13 @@ func (t *Inbound) Start(stage adapter.StartStage) error { if err != nil { return E.Cause(err, "configure tun interface") } + scope.Add(tunInterface.Close) t.logger.Trace("creating stack") t.tunIf = tunInterface if t.platformInterface != nil { err = t.platformInterface.ProcessPlatformOptions(t.platformOptions) if err != nil { - closeError := t.tunIf.Close() - t.tunIf = nil - return E.Errors(E.Cause(err, "process platform options"), closeError) + return E.Cause(err, "process platform options") } } var includeAllNetworks bool @@ -467,6 +474,7 @@ func (t *Inbound) Start(stage adapter.StartStage) error { if err != nil { return err } + scope.Add(tunStack.Close) t.tunStack = tunStack t.logger.Info("started at ", t.tunOptions.Name) case adapter.StartStatePostStart: @@ -512,14 +520,6 @@ func (t *Inbound) InterfaceUpdated(ctx context.Context) { } } -func (t *Inbound) Close() error { - return common.Close( - t.tunStack, - t.tunIf, - t.autoRedirect, - ) -} - func (t *Inbound) JudgeFlow(network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict { if slices.Contains(t.dnsHijackAddress, destination.Addr()) { if network == uint8(header.UDPProtocolNumber) { diff --git a/protocol/vless/inbound.go b/protocol/vless/inbound.go index 4f77fb13..80bb10f6 100644 --- a/protocol/vless/inbound.go +++ b/protocol/vless/inbound.go @@ -102,7 +102,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo return inbound, nil } -func (h *Inbound) Start(stage adapter.StartStage) error { +func (h *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } @@ -111,10 +111,18 @@ func (h *Inbound) Start(stage adapter.StartStage) error { if err != nil { return err } + scope.Add(h.tlsConfig.Close) } if h.transport == nil { - return h.listener.Start() + err := h.listener.Start() + if err != nil { + return err + } + scope.Add(h.listener.Close) + return nil } + scope.Add(h.transport.Close) + scope.Add(h.listener.Close) if common.Contains(h.transport.Network(), N.NetworkTCP) { tcpListener, err := h.listener.ListenTCP() if err != nil { @@ -142,15 +150,6 @@ func (h *Inbound) Start(stage adapter.StartStage) error { return nil } -func (h *Inbound) Close() error { - return common.Close( - h.service, - h.listener, - h.tlsConfig, - h.transport, - ) -} - func (h *Inbound) NewConnection(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/vless/outbound.go b/protocol/vless/outbound.go index f837ac04..c0ece0a2 100644 --- a/protocol/vless/outbound.go +++ b/protocol/vless/outbound.go @@ -170,8 +170,17 @@ func (h *Outbound) CloseIdleConnections() { } } -func (h *Outbound) Close() error { - return common.Close(common.PtrOrNil(h.multiplexDialer), h.transport) +func (h *Outbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { + if stage != adapter.StartStateInitialize { + return nil + } + if h.transport != nil { + scope.Add(h.transport.Close) + } + if h.multiplexDialer != nil { + scope.Add(h.multiplexDialer.Close) + } + return nil } type vlessDialer Outbound diff --git a/protocol/vmess/inbound.go b/protocol/vmess/inbound.go index 8783d3e3..0b4f51cc 100644 --- a/protocol/vmess/inbound.go +++ b/protocol/vmess/inbound.go @@ -100,7 +100,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo return inbound, nil } -func (h *Inbound) Start(stage adapter.StartStage) error { +func (h *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } @@ -108,15 +108,24 @@ func (h *Inbound) Start(stage adapter.StartStage) error { if err != nil { return err } + scope.Add(h.service.Close) if h.tlsConfig != nil { err = h.tlsConfig.Start() if err != nil { return err } + scope.Add(h.tlsConfig.Close) } if h.transport == nil { - return h.listener.Start() + err = h.listener.Start() + if err != nil { + return err + } + scope.Add(h.listener.Close) + return nil } + scope.Add(h.transport.Close) + scope.Add(h.listener.Close) if common.Contains(h.transport.Network(), N.NetworkTCP) { tcpListener, err := h.listener.ListenTCP() if err != nil { @@ -144,15 +153,6 @@ func (h *Inbound) Start(stage adapter.StartStage) error { return nil } -func (h *Inbound) Close() error { - return common.Close( - h.service, - h.listener, - h.tlsConfig, - h.transport, - ) -} - func (h *Inbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { if h.tlsConfig != nil && h.transport == nil { tlsConn, err := tls.ServerHandshake(ctx, conn, h.tlsConfig) diff --git a/protocol/vmess/outbound.go b/protocol/vmess/outbound.go index 14597c17..2d867676 100644 --- a/protocol/vmess/outbound.go +++ b/protocol/vmess/outbound.go @@ -148,8 +148,17 @@ func (h *Outbound) CloseIdleConnections() { } } -func (h *Outbound) Close() error { - return common.Close(common.PtrOrNil(h.multiplexDialer), h.transport) +func (h *Outbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { + if stage != adapter.StartStateInitialize { + return nil + } + if h.transport != nil { + scope.Add(h.transport.Close) + } + if h.multiplexDialer != nil { + scope.Add(h.multiplexDialer.Close) + } + return nil } func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { diff --git a/protocol/wireguard/endpoint.go b/protocol/wireguard/endpoint.go index 27e73456..c80cf86f 100644 --- a/protocol/wireguard/endpoint.go +++ b/protocol/wireguard/endpoint.go @@ -134,8 +134,10 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL return ep, nil } -func (w *Endpoint) Start(stage adapter.StartStage) error { +func (w *Endpoint) Start(stage adapter.StartStage, scope *adapter.Scope) error { switch stage { + case adapter.StartStateInitialize: + scope.Add(w.endpoint.Close) case adapter.StartStateStart: return w.endpoint.Start(false) case adapter.StartStatePostStart: @@ -144,17 +146,16 @@ func (w *Endpoint) Start(stage adapter.StartStage) error { return err } w.started.Store(true) + scope.Add(func() error { + w.bindAccess.Lock() + w.started.Store(false) + w.bindAccess.Unlock() + return nil + }) } return nil } -func (w *Endpoint) Close() error { - w.bindAccess.Lock() - w.started.Store(false) - w.bindAccess.Unlock() - return w.endpoint.Close() -} - func (w *Endpoint) InterfaceUpdated(ctx context.Context) { if !w.started.Load() { return diff --git a/route/conn.go b/route/conn.go index fe718b50..5029853e 100644 --- a/route/conn.go +++ b/route/conn.go @@ -42,7 +42,14 @@ func NewConnectionManager(logger logger.ContextLogger) *ConnectionManager { } } -func (m *ConnectionManager) Start(stage adapter.StartStage) error { +func (m *ConnectionManager) Start(stage adapter.StartStage, scope *adapter.Scope) error { + if stage != adapter.StartStateInitialize { + return nil + } + scope.Add(func() error { + m.CloseAll() + return nil + }) return nil } @@ -65,11 +72,6 @@ func (m *ConnectionManager) CloseAll() { } } -func (m *ConnectionManager) Close() error { - m.CloseAll() - return nil -} - func (m *ConnectionManager) TrackConn(conn net.Conn) net.Conn { m.access.Lock() element := m.connections.PushBack(conn) diff --git a/route/network.go b/route/network.go index 68d1cd2e..53a1be8d 100644 --- a/route/network.go +++ b/route/network.go @@ -25,7 +25,6 @@ import ( "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" "github.com/sagernet/sing/common/winpowrprof" - "github.com/sagernet/sing/common/x/list" "github.com/sagernet/sing/service" "github.com/sagernet/sing/service/pause" @@ -61,8 +60,6 @@ type NetworkManager struct { environmentUpdateAccess sync.Mutex environmentUpdateTimer *time.Timer startedCtx context.Context - startedCancel context.CancelFunc - interfaceUpdateElement *list.Element[tun.DefaultInterfaceUpdateCallback] interfaceUpdateAccess sync.Mutex interfaceUpdateCancel context.CancelFunc networkResetPending bool @@ -146,11 +143,19 @@ func NewNetworkManager(ctx context.Context, logger logger.ContextLogger, options return nm, nil } -func (r *NetworkManager) Start(stage adapter.StartStage) error { +func (r *NetworkManager) Start(stage adapter.StartStage, scope *adapter.Scope) error { monitor := taskmonitor.New(r.logger, C.StartTimeout) switch stage { case adapter.StartStateInitialize: r.router = service.FromContext[adapter.Router](r.ctx) + scope.Add(func() error { + r.environmentUpdateAccess.Lock() + if r.environmentUpdateTimer != nil { + r.environmentUpdateTimer.Stop() + } + r.environmentUpdateAccess.Unlock() + return nil + }) if r.networkMonitor != nil { monitor.Start("initialize network monitor") err := r.networkMonitor.Start() @@ -158,6 +163,7 @@ func (r *NetworkManager) Start(stage adapter.StartStage) error { if err != nil { return err } + scope.Add(r.networkMonitor.Close) } if r.interfaceMonitor != nil { monitor.Start("initialize interface monitor") @@ -166,7 +172,12 @@ func (r *NetworkManager) Start(stage adapter.StartStage) error { if err != nil { return err } - r.interfaceUpdateElement = r.interfaceMonitor.RegisterCallback(r.notifyInterfaceUpdate) + scope.Add(r.interfaceMonitor.Close) + interfaceUpdateElement := r.interfaceMonitor.RegisterCallback(r.notifyInterfaceUpdate) + scope.Add(func() error { + r.interfaceMonitor.UnregisterCallback(interfaceUpdateElement) + return nil + }) } case adapter.StartStateStart: if C.IsAndroid && r.platformInterface == nil { @@ -186,6 +197,7 @@ func (r *NetworkManager) Start(stage adapter.StartStage) error { r.logger.Warn("initialize package manager: ", err) } else { r.packageManager = packageManager + scope.Add(packageManager.Close) } } case adapter.StartStatePostStart: @@ -197,14 +209,20 @@ func (r *NetworkManager) Start(stage adapter.StartStage) error { } } else { r.wifiMonitor = wifiMonitor + scope.Add(wifiMonitor.Close) err = r.wifiMonitor.Start() if err != nil { r.logger.Warn(E.Cause(err, "start WIFI monitor")) } } } + scope.Add(func() error { + r.resetRunAccess.Lock() + defer r.resetRunAccess.Unlock() + return nil + }) r.interfaceUpdateAccess.Lock() - r.startedCtx, r.startedCancel = context.WithCancel(r.ctx) + r.startedCtx = scope.Context() if r.interfaceMonitor != nil { r.dispatchInterfaceUpdateLocked() } @@ -224,6 +242,7 @@ func (r *NetworkManager) Start(stage adapter.StartStage) error { if err != nil { return E.Cause(err, "start power listener") } + scope.Add(r.powerListener.Close) } } return nil @@ -239,63 +258,6 @@ func (r *NetworkManager) Initialize(ruleSets []adapter.RuleSet) { } } -func (r *NetworkManager) Close() error { - monitor := taskmonitor.New(r.logger, C.StopTimeout) - var err error - if r.interfaceUpdateElement != nil { - r.interfaceMonitor.UnregisterCallback(r.interfaceUpdateElement) - r.interfaceUpdateElement = nil - } - if r.powerListener != nil { - monitor.Start("close power listener") - err = E.Append(err, r.powerListener.Close(), func(err error) error { - return E.Cause(err, "close power listener") - }) - monitor.Finish() - } - if r.startedCancel != nil { - r.startedCancel() - monitor.Start("wait network reset") - r.resetRunAccess.Lock() - monitor.Finish() - defer r.resetRunAccess.Unlock() - } - if r.packageManager != nil { - monitor.Start("close package manager") - err = E.Append(err, r.packageManager.Close(), func(err error) error { - return E.Cause(err, "close package manager") - }) - monitor.Finish() - } - if r.interfaceMonitor != nil { - monitor.Start("close interface monitor") - err = E.Append(err, r.interfaceMonitor.Close(), func(err error) error { - return E.Cause(err, "close interface monitor") - }) - monitor.Finish() - } - if r.networkMonitor != nil { - monitor.Start("close network monitor") - err = E.Append(err, r.networkMonitor.Close(), func(err error) error { - return E.Cause(err, "close network monitor") - }) - monitor.Finish() - } - r.environmentUpdateAccess.Lock() - if r.environmentUpdateTimer != nil { - r.environmentUpdateTimer.Stop() - } - r.environmentUpdateAccess.Unlock() - if r.wifiMonitor != nil { - monitor.Start("close WIFI monitor") - err = E.Append(err, r.wifiMonitor.Close(), func(err error) error { - return E.Cause(err, "close WIFI monitor") - }) - monitor.Finish() - } - return err -} - func (r *NetworkManager) InterfaceFinder() control.InterfaceFinder { return r.interfaceFinder } diff --git a/route/reference.go b/route/reference.go index 708efe5b..ac4511f9 100644 --- a/route/reference.go +++ b/route/reference.go @@ -77,11 +77,12 @@ func (m *ReferenceManager) Name() string { return "reference manager" } -func (m *ReferenceManager) Start(stage adapter.StartStage) error { +func (m *ReferenceManager) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStarted { return nil } m.subscriber = observable.NewSubscriber[struct{}](1) + scope.Add(m.subscriber.Close) history := service.PtrFromContext[urltest.HistoryStorage](m.ctx) if history != nil { history.AddUpdateHook(m.subscriber) @@ -95,13 +96,6 @@ func (m *ReferenceManager) Start(stage adapter.StartStage) error { return nil } -func (m *ReferenceManager) Close() error { - if m.subscriber != nil { - m.subscriber.Close() - } - return nil -} - func (m *ReferenceManager) loop() { subscription, done := m.subscriber.Subscription() for { diff --git a/route/router.go b/route/router.go index 682111d4..0673ddef 100644 --- a/route/router.go +++ b/route/router.go @@ -47,7 +47,6 @@ type Router struct { pauseManager pause.Manager trackers []adapter.ConnectionTracker platformInterface adapter.PlatformInterface - started bool } func NewRouter(ctx context.Context, logFactory log.Factory, options option.RouteOptions, dnsOptions option.DNSOptions) *Router { @@ -99,10 +98,13 @@ func (r *Router) Initialize(rules []option.Rule, ruleSets []option.RuleSet) erro return nil } -func (r *Router) Start(stage adapter.StartStage) error { +func (r *Router) Start(stage adapter.StartStage, scope *adapter.Scope) error { monitor := taskmonitor.New(r.logger, C.StartTimeout) switch stage { case adapter.StartStateInitialize: + for _, ruleSet := range r.ruleSets { + scope.Add(ruleSet.Close) + } if r.needFindNeighbor { if r.platformInterface != nil && r.platformInterface.UsePlatformNeighborResolver() { monitor.Start("initialize neighbor resolver") @@ -113,6 +115,7 @@ func (r *Router) Start(stage adapter.StartStage) error { r.logger.Error(E.Cause(err, "start neighbor resolver")) } else { r.neighborResolver = resolver + scope.Add(resolver.Close) } } else { monitor.Start("initialize neighbor resolver") @@ -128,6 +131,7 @@ func (r *Router) Start(stage adapter.StartStage) error { r.logger.Error(E.Cause(err, "start neighbor resolver")) } else { r.neighborResolver = resolver + scope.Add(resolver.Close) } } } @@ -160,6 +164,9 @@ func (r *Router) Start(stage adapter.StartStage) error { startContext.Close() } r.ruleSetUpdater = R.NewRuleSetUpdater(r.ctx, r.ruleSets) + if r.ruleSetUpdater != nil { + scope.Add(r.ruleSetUpdater.Close) + } r.network.Initialize(r.ruleSets) needFindProcess := r.needFindProcess for _, ruleSet := range r.ruleSets { @@ -192,12 +199,14 @@ func (r *Router) Start(stage adapter.StartStage) error { } } if r.processSearcher != nil { + scope.Add(r.processSearcher.Close) processCache := common.Must1(freelru.New[processCacheKey, processCacheEntry](256, maphash.NewHasher[processCacheKey]().Hash32, true)) processCache.SetLifetime(200 * time.Millisecond) r.processCache = processCache } case adapter.StartStatePostStart: for i, rule := range r.rules { + scope.Add(rule.Close) monitor.Start("initialize rule[", i, "]") err := rule.Start() monitor.Finish() @@ -208,7 +217,6 @@ func (r *Router) Start(stage adapter.StartStage) error { if r.ruleSetUpdater != nil { r.ruleSetUpdater.Start() } - r.started = true return nil case adapter.StartStateStarted: for _, ruleSet := range r.ruleSets { @@ -219,47 +227,6 @@ func (r *Router) Start(stage adapter.StartStage) error { return nil } -func (r *Router) Close() error { - monitor := taskmonitor.New(r.logger, C.StopTimeout) - var err error - if r.neighborResolver != nil { - monitor.Start("close neighbor resolver") - err = E.Append(err, r.neighborResolver.Close(), func(closeErr error) error { - return E.Cause(closeErr, "close neighbor resolver") - }) - monitor.Finish() - } - for i, rule := range r.rules { - monitor.Start("close rule[", i, "]") - err = E.Append(err, rule.Close(), func(err error) error { - return E.Cause(err, "close rule[", i, "]") - }) - monitor.Finish() - } - if r.ruleSetUpdater != nil { - monitor.Start("close rule-set updater") - err = E.Append(err, r.ruleSetUpdater.Close(), func(err error) error { - return E.Cause(err, "close rule-set updater") - }) - monitor.Finish() - } - for i, ruleSet := range r.ruleSets { - monitor.Start("close rule-set[", i, "]") - err = E.Append(err, ruleSet.Close(), func(err error) error { - return E.Cause(err, "close rule-set[", i, "]") - }) - monitor.Finish() - } - if r.processSearcher != nil { - monitor.Start("close process searcher") - err = E.Append(err, r.processSearcher.Close(), func(err error) error { - return E.Cause(err, "close process searcher") - }) - monitor.Finish() - } - return err -} - func (r *Router) RuleSet(tag string) (adapter.RuleSet, bool) { ruleSet, loaded := r.ruleSetMap[tag] return ruleSet, loaded diff --git a/route/rule/rule_item_rule_set_test.go b/route/rule/rule_item_rule_set_test.go index 61c3208b..ec34020a 100644 --- a/route/rule/rule_item_rule_set_test.go +++ b/route/rule/rule_item_rule_set_test.go @@ -18,8 +18,7 @@ type ruleSetItemTestRouter struct { ruleSets map[string]adapter.RuleSet } -func (r *ruleSetItemTestRouter) Start(adapter.StartStage) error { return nil } -func (r *ruleSetItemTestRouter) Close() error { return nil } +func (r *ruleSetItemTestRouter) Start(adapter.StartStage, *adapter.Scope) error { return nil } func (r *ruleSetItemTestRouter) PreMatch(adapter.InboundContext, []byte) adapter.PreMatchResult { return adapter.PreMatchResult{} } diff --git a/service/acme/service.go b/service/acme/service.go index 3e1abd0a..0b67d58e 100644 --- a/service/acme/service.go +++ b/service/acme/service.go @@ -50,7 +50,6 @@ type Service struct { certificate.Adapter ctx context.Context config *certmagic.Config - cache *certmagic.Cache zapLogger *zap.Logger dataDirectory string domain []string @@ -186,7 +185,7 @@ func NewCertificateProvider(ctx context.Context, logger log.ContextLogger, tag s }, nil } -func (s *Service) Start(stage adapter.StartStage) error { +func (s *Service) Start(stage adapter.StartStage, scope *adapter.Scope) error { switch stage { case adapter.StartStateInitialize: if s.dataDirectory != "" { @@ -202,22 +201,18 @@ func (s *Service) Start(stage adapter.StartStage) error { }, Logger: s.zapLogger, }) + scope.Add(func() error { + cache.Stop() + return nil + }) config = certmagic.New(cache, *config) s.config = config - s.cache = cache case adapter.StartStateStart: return s.config.ManageAsync(s.ctx, s.domain) } return nil } -func (s *Service) Close() error { - if s.cache != nil { - s.cache.Stop() - } - return nil -} - func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { return s.config.GetCertificate(hello) } diff --git a/service/api/dashboard.go b/service/api/dashboard.go index e03dabe8..0f981716 100644 --- a/service/api/dashboard.go +++ b/service/api/dashboard.go @@ -35,7 +35,6 @@ const ( type dashboard struct { ctx context.Context - cancel context.CancelFunc logger log.ContextLogger options option.APIDashboardOptions path string @@ -48,7 +47,6 @@ type dashboard struct { } func newDashboard(ctx context.Context, logger log.ContextLogger, options option.APIDashboardOptions) *dashboard { - ctx, cancel := context.WithCancel(ctx) path := options.Path if path == "" { path = "dashboard" @@ -64,7 +62,6 @@ func newDashboard(ctx context.Context, logger log.ContextLogger, options option. } return &dashboard{ ctx: ctx, - cancel: cancel, logger: logger, options: options, path: path, @@ -74,7 +71,7 @@ func newDashboard(ctx context.Context, logger log.ContextLogger, options option. } } -func (d *dashboard) start() error { +func (d *dashboard) start(ctx context.Context) error { _, err := filemanager.ReadDir(d.ctx, d.path) if err != nil && !os.IsNotExist(err) { return E.Cause(err, "read dashboard directory") @@ -90,12 +87,11 @@ func (d *dashboard) start() error { return E.Cause(err, "create dashboard http client") } d.httpClient = &http.Client{Transport: transport} - go d.loopUpdate(status) + go d.loopUpdate(ctx, status) return nil } func (d *dashboard) close() error { - d.cancel() if d.httpClient != nil { d.httpClient.CloseIdleConnections() } @@ -110,7 +106,7 @@ func (d *dashboard) serveHTTP(writer http.ResponseWriter, request *http.Request) http.Redirect(writer, request, dashboardRoutePrefix, http.StatusFound) } -func (d *dashboard) loopUpdate(status dashboardStatus) { +func (d *dashboard) loopUpdate(ctx context.Context, status dashboardStatus) { var nextUpdate time.Time if status == dashboardManaged { nextUpdate = d.lastUpdated.Add(d.updateInterval) @@ -119,13 +115,13 @@ func (d *dashboard) loopUpdate(status dashboardStatus) { defer timer.Stop() for { select { - case <-d.ctx.Done(): + case <-ctx.Done(): return case <-timer.C: } now := time.Now() if !now.Before(nextUpdate) { - err := d.fetch(d.ctx) + err := d.fetch(ctx) if err != nil { d.logger.Error(E.Cause(err, "update dashboard")) nextUpdate = now.Add(d.updateInterval) diff --git a/service/api/server.go b/service/api/server.go index 658e6564..87af9644 100644 --- a/service/api/server.go +++ b/service/api/server.go @@ -20,7 +20,6 @@ import ( "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" //nolint:staticcheck - "google.golang.org/grpc" ) func RegisterService(registry *boxService.Registry) { @@ -29,24 +28,16 @@ func RegisterService(registry *boxService.Registry) { type Service struct { boxService.Adapter - ctx context.Context - cancel context.CancelFunc - logger log.ContextLogger - options option.APIServiceOptions - listener *listener.Listener - tlsConfig tls.ServerConfig - startedService *daemon.StartedService - grpcServer *grpc.Server - httpServer *http.Server - dashboard *dashboard + logger log.ContextLogger + options option.APIServiceOptions + listener *listener.Listener + tlsConfig tls.ServerConfig + dashboard *dashboard } func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.APIServiceOptions) (adapter.Service, error) { - ctx, cancel := context.WithCancel(ctx) s := &Service{ Adapter: boxService.NewAdapter(C.TypeAPI, tag), - ctx: ctx, - cancel: cancel, logger: logger, options: options, listener: listener.New(listener.Options{ @@ -59,7 +50,6 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio if options.TLS != nil { tlsConfig, err := tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS)) if err != nil { - cancel() return nil, err } s.tlsConfig = tlsConfig @@ -70,23 +60,33 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio return s, nil } -func (s *Service) Start(stage adapter.StartStage) error { +func (s *Service) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStarted { return nil } - s.startedService = daemon.NewAttachedService(s.ctx) - s.grpcServer = daemon.NewServer(s.startedService, s.options.Secret) + ctx := scope.Context() + startedService := daemon.NewAttachedService(ctx) + scope.Add(func() error { + startedService.Close() + return nil + }) + grpcServer := daemon.NewServer(startedService, s.options.Secret) + scope.Add(func() error { + grpcServer.Stop() + return nil + }) if s.dashboard != nil { - err := s.dashboard.start() + err := s.dashboard.start(ctx) if err != nil { return E.Cause(err, "start dashboard") } + scope.Add(s.dashboard.close) } - s.httpServer = &http.Server{ + httpServer := &http.Server{ //nolint:staticcheck - Handler: h2c.NewHandler(newHTTPHandler(s.logger, s.grpcServer, s.options, s.dashboard), new(http2.Server)), + Handler: h2c.NewHandler(newHTTPHandler(s.logger, grpcServer, s.options, s.dashboard), new(http2.Server)), BaseContext: func(net.Listener) context.Context { - return s.ctx + return ctx }, } if s.tlsConfig != nil { @@ -94,6 +94,7 @@ func (s *Service) Start(stage adapter.StartStage) error { if err != nil { return E.Cause(err, "create TLS config") } + scope.Add(s.tlsConfig.Close) if !common.Contains(s.tlsConfig.NextProtos(), http2.NextProtoTLS) { s.tlsConfig.SetNextProtos(append([]string{http2.NextProtoTLS}, s.tlsConfig.NextProtos()...)) } @@ -105,34 +106,16 @@ func (s *Service) Start(stage adapter.StartStage) error { if err != nil { return err } + scope.Add(s.listener.Close) if s.tlsConfig != nil { tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig) } + scope.Add(httpServer.Close) go func() { - serveErr := s.httpServer.Serve(tcpListener) - if serveErr != nil && s.ctx.Err() == nil { + serveErr := httpServer.Serve(tcpListener) + if serveErr != nil && ctx.Err() == nil { s.logger.Error("serve error: ", serveErr) } }() return nil } - -func (s *Service) Close() error { - s.cancel() - if s.dashboard != nil { - s.dashboard.close() - } - if s.httpServer != nil { - s.httpServer.Close() - } - if s.grpcServer != nil { - s.grpcServer.Stop() - } - if s.startedService != nil { - s.startedService.Close() - } - return common.Close( - common.PtrOrNil(s.listener), - s.tlsConfig, - ) -} diff --git a/service/ccm/service.go b/service/ccm/service.go index a211a3ef..c59521a5 100644 --- a/service/ccm/service.go +++ b/service/ccm/service.go @@ -121,7 +121,6 @@ type Service struct { httpHeaders http.Header listener *listener.Listener tlsConfig tls.ServerConfig - httpServer *http.Server userManager *UserManager accessMutex sync.RWMutex usageTracker *AggregatedUsage @@ -197,11 +196,22 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio return service, nil } -func (s *Service) Start(stage adapter.StartStage) error { +func (s *Service) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } + if s.usageTracker != nil { + scope.Add(func() error { + s.usageTracker.cancelPendingSave() + saveErr := s.usageTracker.Save() + if saveErr != nil { + s.logger.Error("save usage statistics: ", saveErr) + } + return nil + }) + } + s.userManager.UpdateUsers(s.users) credentials, err := platformReadCredentials(s.ctx, s.credentialPath) @@ -220,19 +230,21 @@ func (s *Service) Start(stage adapter.StartStage) error { router := chi.NewRouter() router.Mount("/", s) - s.httpServer = &http.Server{Handler: router} + httpServer := &http.Server{Handler: router} if s.tlsConfig != nil { err = s.tlsConfig.Start() if err != nil { return E.Cause(err, "create TLS config") } + scope.Add(s.tlsConfig.Close) } tcpListener, err := s.listener.ListenTCP() if err != nil { return err } + scope.Add(s.listener.Close) if s.tlsConfig != nil { if !common.Contains(s.tlsConfig.NextProtos(), http2.NextProtoTLS) { @@ -241,8 +253,9 @@ func (s *Service) Start(stage adapter.StartStage) error { tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig) } + scope.Add(httpServer.Close) go func() { - serveErr := s.httpServer.Serve(tcpListener) + serveErr := httpServer.Serve(tcpListener) if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { s.logger.Error("serve error: ", serveErr) } @@ -579,24 +592,6 @@ func (s *Service) handleResponseWithTracking(writer http.ResponseWriter, respons } } -func (s *Service) Close() error { - err := common.Close( - common.PtrOrNil(s.httpServer), - common.PtrOrNil(s.listener), - s.tlsConfig, - ) - - if s.usageTracker != nil { - s.usageTracker.cancelPendingSave() - saveErr := s.usageTracker.Save() - if saveErr != nil { - s.logger.Error("save usage statistics: ", saveErr) - } - } - - return err -} - func (s *Service) References() []string { if s.detour == "" { return nil diff --git a/service/derp/service.go b/service/derp/service.go index 485bc41e..73fa2d7f 100644 --- a/service/derp/service.go +++ b/service/derp/service.go @@ -134,7 +134,7 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio }, nil } -func (d *Service) Start(stage adapter.StartStage) error { +func (d *Service) Start(stage adapter.StartStage, scope *adapter.Scope) error { switch stage { case adapter.StartStateStart: config, err := readDERPConfig(d.ctx, filemanager.BasePath(d.ctx, d.configPath)) @@ -205,11 +205,13 @@ func (d *Service) Start(stage adapter.StartStage) error { if err != nil { return err } + scope.Add(d.tlsConfig.Close) tcpListener, err := d.listener.ListenTCP() if err != nil { return err } + scope.Add(d.listener.Close) if len(d.tlsConfig.NextProtos()) == 0 { d.tlsConfig.SetNextProtos([]string{http2.NextProtoTLS, "http/1.1"}) } else if !common.Contains(d.tlsConfig.NextProtos(), http2.NextProtoTLS) { @@ -335,14 +337,6 @@ func (d *Service) startMeshWithHost(derpServer *derpserver.Server, server *optio return nil } -func (d *Service) Close() error { - err := common.Close( - common.PtrOrNil(d.listener), - d.tlsConfig, - ) - return err -} - var homePage = `

DERP

diff --git a/service/ocm/service.go b/service/ocm/service.go index 5b213e23..cafcfadd 100644 --- a/service/ocm/service.go +++ b/service/ocm/service.go @@ -136,7 +136,6 @@ type Service struct { httpHeaders http.Header listener *listener.Listener tlsConfig tls.ServerConfig - httpServer *http.Server userManager *UserManager accessMutex sync.RWMutex usageTracker *AggregatedUsage @@ -218,11 +217,22 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio return service, nil } -func (s *Service) Start(stage adapter.StartStage) error { +func (s *Service) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } + if s.usageTracker != nil { + scope.Add(func() error { + s.usageTracker.cancelPendingSave() + saveErr := s.usageTracker.Save() + if saveErr != nil { + s.logger.Error("save usage statistics: ", saveErr) + } + return nil + }) + } + s.userManager.UpdateUsers(s.users) credentials, err := platformReadCredentials(s.ctx, s.credentialPath) @@ -241,19 +251,21 @@ func (s *Service) Start(stage adapter.StartStage) error { router := chi.NewRouter() router.Mount("/", s) - s.httpServer = &http.Server{Handler: router} + httpServer := &http.Server{Handler: router} if s.tlsConfig != nil { err = s.tlsConfig.Start() if err != nil { return E.Cause(err, "create TLS config") } + scope.Add(s.tlsConfig.Close) } tcpListener, err := s.listener.ListenTCP() if err != nil { return err } + scope.Add(s.listener.Close) if s.tlsConfig != nil { if !common.Contains(s.tlsConfig.NextProtos(), http2.NextProtoTLS) { @@ -262,8 +274,16 @@ func (s *Service) Start(stage adapter.StartStage) error { tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig) } + scope.Add(httpServer.Close) + scope.Add(func() error { + for _, session := range s.startWebSocketShutdown() { + session.Close() + } + s.webSocketGroup.Wait() + return nil + }) go func() { - serveErr := s.httpServer.Serve(tcpListener) + serveErr := httpServer.Serve(tcpListener) if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { s.logger.Error("serve error: ", serveErr) } @@ -640,30 +660,6 @@ func (s *Service) handleResponseWithTracking(writer http.ResponseWriter, respons } } -func (s *Service) Close() error { - webSocketSessions := s.startWebSocketShutdown() - - err := common.Close( - common.PtrOrNil(s.httpServer), - common.PtrOrNil(s.listener), - s.tlsConfig, - ) - for _, session := range webSocketSessions { - session.Close() - } - s.webSocketGroup.Wait() - - if s.usageTracker != nil { - s.usageTracker.cancelPendingSave() - saveErr := s.usageTracker.Save() - if saveErr != nil { - s.logger.Error("save usage statistics: ", saveErr) - } - } - - return err -} - func (s *Service) registerWebSocketSession(session *webSocketSession) bool { s.webSocketMutex.Lock() defer s.webSocketMutex.Unlock() diff --git a/service/oomkiller/service.go b/service/oomkiller/service.go index b46d7dfa..60fe968f 100644 --- a/service/oomkiller/service.go +++ b/service/oomkiller/service.go @@ -58,10 +58,7 @@ func (s *Service) startTimer() error { } func (s *Service) stopTimer() { - var state timerState - if s.adaptiveTimer != nil { - state = s.adaptiveTimer.stop() - } + state := s.adaptiveTimer.stop() if s.recorder != nil { s.recorder.instanceStopped(state) } diff --git a/service/oomkiller/service_darwin.go b/service/oomkiller/service_darwin.go index a0a07486..8ab64786 100644 --- a/service/oomkiller/service_darwin.go +++ b/service/oomkiller/service_darwin.go @@ -44,7 +44,7 @@ var ( globalServices []*Service ) -func (s *Service) Start(stage adapter.StartStage) error { +func (s *Service) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } @@ -52,6 +52,10 @@ func (s *Service) Start(stage adapter.StartStage) error { if err != nil { return err } + scope.Add(func() error { + s.stopTimer() + return nil + }) if s.timerConfig.policyMode == policyModeNetworkExtension { globalAccess.Lock() isFirst := len(globalServices) == 0 @@ -60,25 +64,21 @@ func (s *Service) Start(stage adapter.StartStage) error { if isFirst { C.startMemoryPressureMonitor() } - } - return nil -} - -func (s *Service) Close() error { - s.stopTimer() - if s.timerConfig.policyMode == policyModeNetworkExtension { - globalAccess.Lock() - for i, svc := range globalServices { - if svc == s { - globalServices = append(globalServices[:i], globalServices[i+1:]...) - break + scope.Add(func() error { + globalAccess.Lock() + for i, svc := range globalServices { + if svc == s { + globalServices = append(globalServices[:i], globalServices[i+1:]...) + break + } } - } - isLast := len(globalServices) == 0 - globalAccess.Unlock() - if isLast { - C.stopMemoryPressureMonitor() - } + isLast := len(globalServices) == 0 + globalAccess.Unlock() + if isLast { + C.stopMemoryPressureMonitor() + } + return nil + }) } return nil } diff --git a/service/oomkiller/service_stub.go b/service/oomkiller/service_stub.go index f75fe8d5..2025c731 100644 --- a/service/oomkiller/service_stub.go +++ b/service/oomkiller/service_stub.go @@ -6,14 +6,17 @@ import ( "github.com/sagernet/sing-box/adapter" ) -func (s *Service) Start(stage adapter.StartStage) error { +func (s *Service) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } - return s.startTimer() -} - -func (s *Service) Close() error { - s.stopTimer() + err := s.startTimer() + if err != nil { + return err + } + scope.Add(func() error { + s.stopTimer() + return nil + }) return nil } diff --git a/service/origin_ca/service.go b/service/origin_ca/service.go index 46d7337e..2eb1c827 100644 --- a/service/origin_ca/service.go +++ b/service/origin_ca/service.go @@ -60,8 +60,6 @@ type Service struct { certificate.Adapter logger log.ContextLogger ctx context.Context - cancel context.CancelFunc - done chan struct{} timeFunc func() time.Time httpClient *http.Client storage certmagic.Storage @@ -104,10 +102,8 @@ func NewCertificateProvider(ctx context.Context, logger log.ContextLogger, tag s if requestedValidity == 0 { requestedValidity = defaultRequestedValidity } - ctx, cancel := context.WithCancel(ctx) httpClient, err := originCAHTTPClient(ctx, logger, options) if err != nil { - cancel() return nil, err } var ( @@ -135,7 +131,6 @@ func NewCertificateProvider(ctx context.Context, logger log.ContextLogger, tag s Adapter: certificate.NewAdapter(C.TypeCloudflareOriginCA, tag), logger: logger, ctx: ctx, - cancel: cancel, timeFunc: timeFunc, httpClient: httpClient, storage: storage, @@ -161,7 +156,7 @@ func originCAHTTPClient(ctx context.Context, logger log.ContextLogger, options o return &http.Client{Transport: transport}, nil } -func (s *Service) Start(stage adapter.StartStage) error { +func (s *Service) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage == adapter.StartStateInitialize { if s.dataDirectory == "" { return nil @@ -174,33 +169,30 @@ func (s *Service) Start(stage adapter.StartStage) error { } else if stage != adapter.StartStateStart { return nil } - cachedCertificate, cachedLeaf, err := s.loadCachedCertificate() + ctx := scope.Context() + cachedCertificate, cachedLeaf, err := s.loadCachedCertificate(ctx) if err != nil { s.logger.Warn(E.Cause(err, "load cached Cloudflare Origin CA certificate")) } else if cachedCertificate != nil { s.setCurrentCertificate(cachedCertificate, cachedLeaf) } if cachedCertificate == nil { - err = s.issueAndStoreCertificate() + err = s.issueAndStoreCertificate(ctx) if err != nil { return err } } else if s.shouldRenew(cachedLeaf, s.timeFunc()) { - err = s.issueAndStoreCertificate() + err = s.issueAndStoreCertificate(ctx) if err != nil { s.logger.Warn(E.Cause(err, "renew cached Cloudflare Origin CA certificate")) } } - s.done = make(chan struct{}) - go s.refreshLoop() - return nil -} - -func (s *Service) Close() error { - s.cancel() - if done := s.done; done != nil { + done := make(chan struct{}) + go s.refreshLoop(ctx, done) + scope.Add(func() error { <-done - } + return nil + }) return nil } @@ -214,8 +206,8 @@ func (s *Service) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, erro return certificate, nil } -func (s *Service) refreshLoop() { - defer close(s.done) +func (s *Service) refreshLoop(ctx context.Context, done chan struct{}) { + defer close(done) var retryDelay time.Duration for { waitDuration := retryDelay @@ -232,7 +224,7 @@ func (s *Service) refreshLoop() { } timer := time.NewTimer(waitDuration) select { - case <-s.ctx.Done(): + case <-ctx.Done(): if !timer.Stop() { select { case <-timer.C: @@ -242,7 +234,7 @@ func (s *Service) refreshLoop() { return case <-timer.C: } - err := s.issueAndStoreCertificate() + err := s.issueAndStoreCertificate(ctx) if err != nil { s.logger.Error(E.Cause(err, "renew Cloudflare Origin CA certificate")) s.access.RLock() @@ -279,25 +271,25 @@ func (s *Service) effectiveRenewBefore(leaf *x509.Certificate) time.Duration { return min(lifetime/3, defaultRenewBefore) } -func (s *Service) issueAndStoreCertificate() error { - err := s.storage.Lock(s.ctx, s.storageLockKey) +func (s *Service) issueAndStoreCertificate(ctx context.Context) error { + err := s.storage.Lock(ctx, s.storageLockKey) if err != nil { return E.Cause(err, "lock Cloudflare Origin CA certificate storage") } defer func() { - err = s.storage.Unlock(context.WithoutCancel(s.ctx), s.storageLockKey) + err = s.storage.Unlock(context.WithoutCancel(ctx), s.storageLockKey) if err != nil { s.logger.Warn(E.Cause(err, "unlock Cloudflare Origin CA certificate storage")) } }() - cachedCertificate, cachedLeaf, err := s.loadCachedCertificate() + cachedCertificate, cachedLeaf, err := s.loadCachedCertificate(ctx) if err != nil { s.logger.Warn(E.Cause(err, "load cached Cloudflare Origin CA certificate")) } else if cachedCertificate != nil && !s.shouldRenew(cachedLeaf, s.timeFunc()) { s.setCurrentCertificate(cachedCertificate, cachedLeaf) return nil } - certificatePEM, privateKeyPEM, tlsCertificate, leaf, err := s.requestCertificate(s.ctx) + certificatePEM, privateKeyPEM, tlsCertificate, leaf, err := s.requestCertificate(ctx) if err != nil { return err } @@ -308,7 +300,7 @@ func (s *Service) issueAndStoreCertificate() error { if err != nil { return E.Cause(err, "encode Cloudflare Origin CA certificate metadata") } - err = storeCertificateResource(s.ctx, s.storage, s.storageIssuerKey, certmagic.CertificateResource{ + err = storeCertificateResource(ctx, s.storage, s.storageIssuerKey, certmagic.CertificateResource{ SANs: slices.Clone(s.domain), CertificatePEM: certificatePEM, PrivateKeyPEM: privateKeyPEM, @@ -415,8 +407,8 @@ func (s *Service) requestCertificate(ctx context.Context) ([]byte, []byte, *tls. return certificatePEM, privateKeyPEM, tlsCertificate, leaf, nil } -func (s *Service) loadCachedCertificate() (*tls.Certificate, *x509.Certificate, error) { - certificateResource, err := loadCertificateResource(s.ctx, s.storage, s.storageIssuerKey, s.storageNamesKey) +func (s *Service) loadCachedCertificate(ctx context.Context) (*tls.Certificate, *x509.Certificate, error) { + certificateResource, err := loadCertificateResource(ctx, s.storage, s.storageIssuerKey, s.storageNamesKey) if err != nil { if errors.Is(err, fs.ErrNotExist) { return nil, nil, nil diff --git a/service/resolved/service.go b/service/resolved/service.go index f0d1aa0f..85641320 100644 --- a/service/resolved/service.go +++ b/service/resolved/service.go @@ -17,14 +17,12 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" dnsOutbound "github.com/sagernet/sing-box/protocol/dns" - tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/buf" "github.com/sagernet/sing/common/control" E "github.com/sagernet/sing/common/exceptions" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" - "github.com/sagernet/sing/common/x/list" "github.com/sagernet/sing/service" "github.com/godbus/dbus/v5" @@ -37,18 +35,17 @@ func RegisterService(registry *boxService.Registry) { type Service struct { boxService.Adapter - ctx context.Context - logger log.ContextLogger - network adapter.NetworkManager - dnsRouter adapter.DNSRouter - listener *listener.Listener - systemBus *dbus.Conn - linkAccess sync.RWMutex - links map[int32]*TransportLink - defaultRouteSequence []int32 - networkUpdateCallback *list.Element[tun.NetworkUpdateCallback] - updateCallback func(*TransportLink) error - deleteCallback func(*TransportLink) + ctx context.Context + logger log.ContextLogger + network adapter.NetworkManager + dnsRouter adapter.DNSRouter + listener *listener.Listener + systemBus *dbus.Conn + linkAccess sync.RWMutex + links map[int32]*TransportLink + defaultRouteSequence []int32 + updateCallback func(*TransportLink) error + deleteCallback func(*TransportLink) } type TransportLink struct { @@ -82,7 +79,7 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio return inbound, nil } -func (i *Service) Start(stage adapter.StartStage) error { +func (i *Service) Start(stage adapter.StartStage, scope *adapter.Scope) error { switch stage { case adapter.StartStateInitialize: inboundManager := service.FromContext[adapter.ServiceManager](i.ctx) @@ -95,6 +92,7 @@ func (i *Service) Start(stage adapter.StartStage) error { if err != nil { return err } + scope.Add(systemBus.Close) i.systemBus = systemBus err = systemBus.Export((*resolve1Manager)(i), "/org/freedesktop/resolve1", "org.freedesktop.resolve1.Manager") if err != nil { @@ -104,6 +102,10 @@ func (i *Service) Start(stage adapter.StartStage) error { if err != nil { return err } + scope.Add(func() error { + _, releaseErr := systemBus.ReleaseName("org.freedesktop.resolve1") + return releaseErr + }) switch reply { case dbus.RequestNameReplyPrimaryOwner: case dbus.RequestNameReplyExists: @@ -111,27 +113,22 @@ func (i *Service) Start(stage adapter.StartStage) error { default: return E.New("unknown request name reply: ", reply) } - i.networkUpdateCallback = i.network.NetworkMonitor().RegisterCallback(i.onNetworkUpdate) + networkMonitor := i.network.NetworkMonitor() + networkUpdateCallback := networkMonitor.RegisterCallback(i.onNetworkUpdate) + scope.Add(func() error { + networkMonitor.UnregisterCallback(networkUpdateCallback) + return nil + }) case adapter.StartStateStart: err := i.listener.Start() if err != nil { return err } + scope.Add(i.listener.Close) } return nil } -func (i *Service) Close() error { - if i.networkUpdateCallback != nil { - i.network.NetworkMonitor().UnregisterCallback(i.networkUpdateCallback) - } - if i.systemBus != nil { - i.systemBus.ReleaseName("org.freedesktop.resolve1") - i.systemBus.Close() - } - return i.listener.Close() -} - func (i *Service) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { metadata.Inbound = i.Tag() metadata.InboundType = i.Type() diff --git a/service/resolved/transport.go b/service/resolved/transport.go index 2f476a76..34dfa332 100644 --- a/service/resolved/transport.go +++ b/service/resolved/transport.go @@ -58,6 +58,7 @@ type LinkServers struct { Link *TransportLink Servers []adapter.DNSTransport serverOffset uint32 + serverScope *adapter.Scope } func (c *LinkServers) ServerOffset(rotate bool) uint32 { @@ -85,7 +86,7 @@ func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, opt }, nil } -func (t *Transport) Start(stage adapter.StartStage) error { +func (t *Transport) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateInitialize { return nil } @@ -101,17 +102,14 @@ func (t *Transport) Start(stage adapter.StartStage) error { resolvedInbound.updateCallback = t.updateTransports resolvedInbound.deleteCallback = t.deleteTransport t.service = resolvedInbound - return nil -} - -func (t *Transport) Close() error { - t.linkAccess.RLock() - defer t.linkAccess.RUnlock() - for _, servers := range t.linkServers { - for _, server := range servers.Servers { - server.Close() + scope.Add(func() error { + t.linkAccess.RLock() + defer t.linkAccess.RUnlock() + for _, servers := range t.linkServers { + servers.serverScope.Close() } - } + return nil + }) return nil } @@ -171,9 +169,7 @@ func (t *Transport) updateTransports(link *TransportLink) error { t.linkAccess.Lock() defer t.linkAccess.Unlock() if servers, loaded := t.linkServers[link]; loaded { - for _, server := range servers.Servers { - server.Close() - } + servers.serverScope.Close() } serverDialer := common.Must1(dialer.NewDefault(t.ctx, option.DialerOptions{ AbstractDialerOptions: option.AbstractDialerOptions{ @@ -227,9 +223,17 @@ func (t *Transport) updateTransports(link *TransportLink) error { transports = append(transports, transport.NewUDPRaw(t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, serverPort))) } } + serverScope := adapter.NewScope(t.ctx, t.logger) + for _, serverTransport := range transports { + err := serverTransport.Start(adapter.StartStateStart, serverScope) + if err != nil { + return E.Errors(err, serverScope.Close()) + } + } t.linkServers[link] = &LinkServers{ - Link: link, - Servers: transports, + Link: link, + Servers: transports, + serverScope: serverScope, } return nil } @@ -241,9 +245,7 @@ func (t *Transport) deleteTransport(link *TransportLink) { if !loaded { return } - for _, server := range servers.Servers { - server.Close() - } + servers.serverScope.Close() delete(t.linkServers, link) } diff --git a/service/ssmapi/server.go b/service/ssmapi/server.go index 157ea150..16a126ac 100644 --- a/service/ssmapi/server.go +++ b/service/ssmapi/server.go @@ -31,7 +31,6 @@ func RegisterService(registry *boxService.Registry) { type Service struct { boxService.Adapter ctx context.Context - cancel context.CancelFunc logger log.ContextLogger listener *listener.Listener tlsConfig tls.ServerConfig @@ -39,18 +38,15 @@ type Service struct { traffics map[string]*TrafficManager users map[string]*UserManager cachePath string - saveTicker *time.Ticker lastSavedCache []byte cacheMutex sync.Mutex } func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.SSMAPIServiceOptions) (adapter.Service, error) { - ctx, cancel := context.WithCancel(ctx) chiRouter := chi.NewRouter() s := &Service{ Adapter: boxService.NewAdapter(C.TypeSSMAPI, tag), ctx: ctx, - cancel: cancel, logger: logger, listener: listener.New(listener.Options{ Context: ctx, @@ -95,7 +91,7 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio return s, nil } -func (s *Service) Start(stage adapter.StartStage) error { +func (s *Service) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } @@ -103,24 +99,35 @@ func (s *Service) Start(stage adapter.StartStage) error { if err != nil { s.logger.Error(E.Cause(err, "load cache")) } - s.saveTicker = time.NewTicker(1 * time.Minute) - go s.loopSaveCache() + saveTicker := time.NewTicker(1 * time.Minute) + scope.Add(func() error { + saveTicker.Stop() + saveErr := s.saveCache() + if saveErr != nil { + s.logger.Error(E.Cause(saveErr, "save cache")) + } + return nil + }) + go s.loopSaveCache(scope.Context(), saveTicker) if s.tlsConfig != nil { err = s.tlsConfig.Start() if err != nil { return E.Cause(err, "create TLS config") } + scope.Add(s.tlsConfig.Close) } tcpListener, err := s.listener.ListenTCP() if err != nil { return err } + scope.Add(s.listener.Close) 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) } + scope.Add(s.httpServer.Close) go func() { err = s.httpServer.Serve(tcpListener) if err != nil && !errors.Is(err, http.ErrServerClosed) { @@ -130,12 +137,12 @@ func (s *Service) Start(stage adapter.StartStage) error { return nil } -func (s *Service) loopSaveCache() { +func (s *Service) loopSaveCache(ctx context.Context, saveTicker *time.Ticker) { for { select { - case <-s.ctx.Done(): + case <-ctx.Done(): return - case <-s.saveTicker.C: + case <-saveTicker.C: err := s.saveCache() if err != nil { s.logger.Error(E.Cause(err, "save cache")) @@ -143,21 +150,3 @@ func (s *Service) loopSaveCache() { } } } - -func (s *Service) Close() error { - if s.cancel != nil { - s.cancel() - } - if s.saveTicker != nil { - s.saveTicker.Stop() - } - err := s.saveCache() - if err != nil { - s.logger.Error(E.Cause(err, "save cache")) - } - return common.Close( - common.PtrOrNil(s.httpServer), - common.PtrOrNil(s.listener), - s.tlsConfig, - ) -} diff --git a/service/usbip/client.go b/service/usbip/client.go index 231c447f..9e0ddcad 100644 --- a/service/usbip/client.go +++ b/service/usbip/client.go @@ -52,15 +52,16 @@ func NewClientService(ctx context.Context, logger log.ContextLogger, tag string, }, nil } -func (s *ClientService) Start(stage adapter.StartStage) error { +func (s *ClientService) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } - return s.inner.Start() -} - -func (s *ClientService) Close() error { - return s.inner.Close() + err := s.inner.Start() + if err != nil { + return err + } + scope.Add(s.inner.Close) + return nil } func (s *ClientService) References() []string { diff --git a/service/usbip/server.go b/service/usbip/server.go index d1483d94..abeb8ddc 100644 --- a/service/usbip/server.go +++ b/service/usbip/server.go @@ -83,15 +83,16 @@ func NewServerService(ctx context.Context, logger log.ContextLogger, tag string, } } -func (s *ServerService) Start(stage adapter.StartStage) error { +func (s *ServerService) Start(stage adapter.StartStage, scope *adapter.Scope) error { if stage != adapter.StartStateStart { return nil } - return s.inner.Start() -} - -func (s *ServerService) Close() error { - return s.inner.Close() + err := s.inner.Start() + if err != nil { + return err + } + scope.Add(s.inner.Close) + return nil } func (s *dynamicServerService) AddDevice(info usbip.ProvidedDeviceInfo, transport usbip.DeviceTransport) (string, error) { From f9fcb8964f0b66e92a06f777d2ca59ce0e8949ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 1 Oct 2026 23:28:24 +0800 Subject: [PATCH 59/66] Fix usage and cache files overwritten when loading fails --- service/ccm/service.go | 23 +++++++++++------------ service/ocm/service.go | 23 +++++++++++------------ service/ssmapi/server.go | 23 ++++++++++++----------- 3 files changed, 34 insertions(+), 35 deletions(-) diff --git a/service/ccm/service.go b/service/ccm/service.go index c59521a5..0d89527d 100644 --- a/service/ccm/service.go +++ b/service/ccm/service.go @@ -201,17 +201,6 @@ func (s *Service) Start(stage adapter.StartStage, scope *adapter.Scope) error { return nil } - if s.usageTracker != nil { - scope.Add(func() error { - s.usageTracker.cancelPendingSave() - saveErr := s.usageTracker.Save() - if saveErr != nil { - s.logger.Error("save usage statistics: ", saveErr) - } - return nil - }) - } - s.userManager.UpdateUsers(s.users) credentials, err := platformReadCredentials(s.ctx, s.credentialPath) @@ -223,7 +212,17 @@ func (s *Service) Start(stage adapter.StartStage, scope *adapter.Scope) error { if s.usageTracker != nil { err = s.usageTracker.Load() if err != nil { - s.logger.Warn("load usage statistics: ", err) + s.logger.Error("load usage statistics: ", err, ", usage tracking and saving disabled") + s.usageTracker = nil + } else { + scope.Add(func() error { + s.usageTracker.cancelPendingSave() + saveErr := s.usageTracker.Save() + if saveErr != nil { + s.logger.Error("save usage statistics: ", saveErr) + } + return nil + }) } } diff --git a/service/ocm/service.go b/service/ocm/service.go index cafcfadd..300d158e 100644 --- a/service/ocm/service.go +++ b/service/ocm/service.go @@ -222,17 +222,6 @@ func (s *Service) Start(stage adapter.StartStage, scope *adapter.Scope) error { return nil } - if s.usageTracker != nil { - scope.Add(func() error { - s.usageTracker.cancelPendingSave() - saveErr := s.usageTracker.Save() - if saveErr != nil { - s.logger.Error("save usage statistics: ", saveErr) - } - return nil - }) - } - s.userManager.UpdateUsers(s.users) credentials, err := platformReadCredentials(s.ctx, s.credentialPath) @@ -244,7 +233,17 @@ func (s *Service) Start(stage adapter.StartStage, scope *adapter.Scope) error { if s.usageTracker != nil { err = s.usageTracker.Load() if err != nil { - s.logger.Warn("load usage statistics: ", err) + s.logger.Error("load usage statistics: ", err, ", usage tracking and saving disabled") + s.usageTracker = nil + } else { + scope.Add(func() error { + s.usageTracker.cancelPendingSave() + saveErr := s.usageTracker.Save() + if saveErr != nil { + s.logger.Error("save usage statistics: ", saveErr) + } + return nil + }) } } diff --git a/service/ssmapi/server.go b/service/ssmapi/server.go index 16a126ac..381b4d60 100644 --- a/service/ssmapi/server.go +++ b/service/ssmapi/server.go @@ -97,18 +97,19 @@ func (s *Service) Start(stage adapter.StartStage, scope *adapter.Scope) error { } err := s.loadCache() if err != nil { - s.logger.Error(E.Cause(err, "load cache")) + s.logger.Error("load cache: ", err, ", saving disabled") + } else { + saveTicker := time.NewTicker(1 * time.Minute) + scope.Add(func() error { + saveTicker.Stop() + saveErr := s.saveCache() + if saveErr != nil { + s.logger.Error(E.Cause(saveErr, "save cache")) + } + return nil + }) + go s.loopSaveCache(scope.Context(), saveTicker) } - saveTicker := time.NewTicker(1 * time.Minute) - scope.Add(func() error { - saveTicker.Stop() - saveErr := s.saveCache() - if saveErr != nil { - s.logger.Error(E.Cause(saveErr, "save cache")) - } - return nil - }) - go s.loopSaveCache(scope.Context(), saveTicker) if s.tlsConfig != nil { err = s.tlsConfig.Start() if err != nil { From 02537831e1883a449d0c1b1511e9889740d62e80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 1 Oct 2026 23:32:10 +0800 Subject: [PATCH 60/66] Move auto-redirect and bridge index allocation out of constructors --- protocol/bridge/backend.go | 19 +++++++---- protocol/bridge/backend_darwin.go | 17 ++++------ protocol/bridge/backend_linux.go | 19 +++++------ protocol/bridge/backend_windows.go | 10 ++---- protocol/tun/inbound.go | 51 ++++++++++++++++-------------- 5 files changed, 58 insertions(+), 58 deletions(-) diff --git a/protocol/bridge/backend.go b/protocol/bridge/backend.go index 2c48af97..98143050 100644 --- a/protocol/bridge/backend.go +++ b/protocol/bridge/backend.go @@ -50,21 +50,28 @@ type backendBase struct { readGroup sync.WaitGroup } -func (b *backendBase) init(ctx context.Context, logger logger.ContextLogger, networkManager adapter.NetworkManager, tag string, options option.BridgeOutboundOptions) error { - index, err := allocateBridgeIndex() - if err != nil { - return err - } +func (b *backendBase) init(ctx context.Context, logger logger.ContextLogger, networkManager adapter.NetworkManager, tag string, options option.BridgeOutboundOptions) { b.ctx = ctx b.logger = logger b.networkManager = networkManager b.tag = tag - b.index = index b.bridgeName = options.BridgeName if b.bridgeName == "" { b.bridgeName = "bridge" } b.boundInterface = options.Interface +} + +func (b *backendBase) allocateIndex(scope *adapter.Scope) error { + index, err := allocateBridgeIndex() + if err != nil { + return err + } + scope.Add(func() error { + releaseBridgeIndex(index) + return nil + }) + b.index = index b.inet4Port = addressAt(bridgeInet4Base, index) b.inet6Port = addressAt(bridgeInet6Base, index) return nil diff --git a/protocol/bridge/backend_darwin.go b/protocol/bridge/backend_darwin.go index 4da636d7..a5a9c68a 100644 --- a/protocol/bridge/backend_darwin.go +++ b/protocol/bridge/backend_darwin.go @@ -59,12 +59,7 @@ func newBackend(ctx context.Context, logger logger.ContextLogger, networkManager instance := &backendDarwin{ writeBatch: make([]*buf.Buffer, 0, bridgeWriteBatchSize), } - err := instance.init(ctx, logger, networkManager, tag, options) - if err != nil { - return nil, err - } - instance.inet4Local = addressAt(bridgeInet4LocalBase, instance.index) - instance.inet6Local = addressAt(bridgeInet6LocalBase, instance.index) + instance.init(ctx, logger, networkManager, tag, options) platformInterface := service.FromContext[adapter.PlatformInterface](ctx) if platformInterface != nil && platformInterface.UsePlatformBridge() { instance.platform = platformInterface @@ -75,10 +70,12 @@ func newBackend(ctx context.Context, logger logger.ContextLogger, networkManager func (b *backendDarwin) Start(stage adapter.StartStage, scope *adapter.Scope) error { switch stage { case adapter.StartStateInitialize: - scope.Add(func() error { - releaseBridgeIndex(b.index) - return nil - }) + err := b.allocateIndex(scope) + if err != nil { + return err + } + b.inet4Local = addressAt(bridgeInet4LocalBase, b.index) + b.inet6Local = addressAt(bridgeInet6LocalBase, b.index) case adapter.StartStateStart: b.closed = scope.Context().Done() if b.platform != nil { diff --git a/protocol/bridge/backend_linux.go b/protocol/bridge/backend_linux.go index ce6e1176..8bc6a4e3 100644 --- a/protocol/bridge/backend_linux.go +++ b/protocol/bridge/backend_linux.go @@ -42,10 +42,7 @@ type backendLinux struct { func newBackend(ctx context.Context, logger logger.ContextLogger, networkManager adapter.NetworkManager, tag string, options option.BridgeOutboundOptions) (Backend, error) { instance := &backendLinux{} - err := instance.init(ctx, logger, networkManager, tag, options) - if err != nil { - return nil, err - } + instance.init(ctx, logger, networkManager, tag, options) platformInterface := service.FromContext[adapter.PlatformInterface](ctx) if platformInterface != nil && platformInterface.UsePlatformBridge() { instance.platform = platformInterface @@ -56,9 +53,6 @@ func newBackend(ctx context.Context, logger logger.ContextLogger, networkManager } if instance.boundInterface != "" || instance.platform != nil { instance.routeTable = options.IPRoute2TableIndex - if instance.routeTable == 0 { - instance.routeTable = defaultBridgeTableIndexBase + int(instance.index) - } } return instance, nil } @@ -66,10 +60,13 @@ func newBackend(ctx context.Context, logger logger.ContextLogger, networkManager func (b *backendLinux) Start(stage adapter.StartStage, scope *adapter.Scope) error { switch stage { case adapter.StartStateInitialize: - scope.Add(func() error { - releaseBridgeIndex(b.index) - return nil - }) + err := b.allocateIndex(scope) + if err != nil { + return err + } + if b.routeTable == 0 && (b.boundInterface != "" || b.platform != nil) { + b.routeTable = defaultBridgeTableIndexBase + int(b.index) + } case adapter.StartStateStart: b.closed = scope.Context().Done() if b.platform != nil { diff --git a/protocol/bridge/backend_windows.go b/protocol/bridge/backend_windows.go index af877ce1..9709a180 100644 --- a/protocol/bridge/backend_windows.go +++ b/protocol/bridge/backend_windows.go @@ -133,20 +133,14 @@ type backendWindows struct { func newBackend(ctx context.Context, logger logger.ContextLogger, networkManager adapter.NetworkManager, tag string, options option.BridgeOutboundOptions) (Backend, error) { instance := &backendWindows{} - err := instance.init(ctx, logger, networkManager, tag, options) - if err != nil { - return nil, err - } + instance.init(ctx, logger, networkManager, tag, options) return instance, nil } func (b *backendWindows) Start(stage adapter.StartStage, scope *adapter.Scope) error { switch stage { case adapter.StartStateInitialize: - scope.Add(func() error { - releaseBridgeIndex(b.index) - return nil - }) + return b.allocateIndex(scope) case adapter.StartStateStart: return b.start(scope) } diff --git a/protocol/tun/inbound.go b/protocol/tun/inbound.go index 9347a96e..18c8fd0f 100644 --- a/protocol/tun/inbound.go +++ b/protocol/tun/inbound.go @@ -51,6 +51,8 @@ type Inbound struct { tunStack tun.Stack platformInterface adapter.PlatformInterface platformOptions option.TunPlatformOptions + enableAutoRedirect bool + disableNFTables bool autoRedirect tun.AutoRedirect routeRuleSet []adapter.RuleSet routeExcludeRuleSet []adapter.RuleSet @@ -256,22 +258,12 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo if !options.AutoRoute { return nil, E.New("`auto_route` is required by `auto_redirect`") } - disableNFTables, dErr := strconv.ParseBool(os.Getenv("DISABLE_NFTABLES")) - inbound.autoRedirect, err = tun.NewAutoRedirect(tun.AutoRedirectOptions{ - TunOptions: &inbound.tunOptions, - Context: ctx, - Handler: (*autoRedirectHandler)(inbound), - Logger: logger, - NetworkMonitor: networkManager.NetworkMonitor(), - InterfaceFinder: networkManager.InterfaceFinder(), - TableName: "sing-box", - DisableNFTables: dErr == nil && disableNFTables, - RouteAddressSet: &inbound.routeAddressSet, - RouteExcludeAddressSet: &inbound.routeExcludeAddressSet, - }) - if err != nil { - return nil, E.Cause(err, "initialize auto-redirect") + if !C.IsLinux { + return nil, E.New("`auto_redirect` is only supported on Linux") } + disableNFTables, parseErr := strconv.ParseBool(os.Getenv("DISABLE_NFTABLES")) + inbound.enableAutoRedirect = true + inbound.disableNFTables = parseErr == nil && disableNFTables if !C.IsAndroid { inbound.tunOptions.AutoRedirectMarkMode = true if options.NetNs == "" { @@ -328,9 +320,6 @@ func (t *Inbound) Tag() string { func (t *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { switch stage { case adapter.StartStateInitialize: - if t.autoRedirect != nil { - scope.Add(t.autoRedirect.Close) - } if t.tunOptions.DNSModeOrDefault() != tun.DNSModeDisabled && len(t.tunOptions.DNSAddress) == 0 { inet4DNSAddress, _ := t.tunOptions.Inet4DNSAddress() inet6DNSAddress, _ := t.tunOptions.Inet6DNSAddress() @@ -374,6 +363,24 @@ func (t *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { t.tunOptions.NetNs = manager.ResolvePath(t.tunOptions.NetNs) } } + var err error + if t.enableAutoRedirect { + t.autoRedirect, err = tun.NewAutoRedirect(tun.AutoRedirectOptions{ + TunOptions: &t.tunOptions, + Context: t.ctx, + Handler: (*autoRedirectHandler)(t), + Logger: t.logger, + NetworkMonitor: t.networkManager.NetworkMonitor(), + InterfaceFinder: t.networkManager.InterfaceFinder(), + TableName: "sing-box", + DisableNFTables: t.disableNFTables, + RouteAddressSet: &t.routeAddressSet, + RouteExcludeAddressSet: &t.routeExcludeAddressSet, + }) + if err != nil { + return E.Cause(err, "initialize auto-redirect") + } + } if t.platformInterface == nil || C.IsWindows { for _, routeRuleSet := range t.routeRuleSet { ipSets := routeRuleSet.ExtractIPSet() @@ -406,10 +413,7 @@ func (t *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { } } } - var ( - tunInterface tun.Tun - err error - ) + var tunInterface tun.Tun monitor := taskmonitor.New(t.logger, C.StartTimeout) tunOptions := t.tunOptions if t.autoRedirect == nil && !(runtime.GOOS == "android" && t.platformInterface != nil) { @@ -492,8 +496,9 @@ func (t *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { return E.Cause(err, "starting TUN interface") } if t.autoRedirect != nil { + scope.Add(t.autoRedirect.Close) monitor.Start("initialize auto-redirect") - err := t.autoRedirect.Start() + err = t.autoRedirect.Start() monitor.Finish() if err != nil { return E.Cause(err, "auto-redirect") From 67fb235ee802bc852302c02937936ea5e37ab557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 7 Oct 2026 18:49:40 +0800 Subject: [PATCH 61/66] Add roothide package to iOS jailbreak release --- .github/workflows/build.yml | 1 + Makefile | 1 + docs/clients/apple/index.md | 6 ++++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 15d115b9..c9a32960 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1596,6 +1596,7 @@ jobs: make -C clients/apple build_ios_deb mkdir -p dist cp "clients/apple/build/jailbreak/SFI-${VERSION}-iphoneos-arm64.deb" dist + cp "clients/apple/build/jailbreak/SFI-${VERSION}-iphoneos-arm64e.deb" dist env: XCODEBUILD_CLONED_SOURCE_PACKAGES_DIR_PATH: ${{ runner.temp }}/SourcePackages - name: Report cache directory sizes diff --git a/Makefile b/Makefile index 949248a4..7b0e1860 100644 --- a/Makefile +++ b/Makefile @@ -125,6 +125,7 @@ build_ios_deb: upload_ios_deb: ghr --replace --draft --prerelease "v${VERSION}" ../sing-box-for-apple/build/jailbreak/"SFI-${VERSION}-iphoneos-arm64.deb" + ghr --replace --draft --prerelease "v${VERSION}" ../sing-box-for-apple/build/jailbreak/"SFI-${VERSION}-iphoneos-arm64e.deb" release_ios: build_ios upload_ios_app_store diff --git a/docs/clients/apple/index.md b/docs/clients/apple/index.md index 892eb4e6..73012523 100644 --- a/docs/clients/apple/index.md +++ b/docs/clients/apple/index.md @@ -24,9 +24,11 @@ or sending us your Apple ID [via email](mailto:contact@sagernet.org). ## :material-cellphone-arrow-down: Download (iOS jailbreak version) -* [GitHub Releases](https://github.com/SagerNet/sing-box/releases) (`SFI-iphoneos-arm64.deb`) +* [GitHub Releases](https://github.com/SagerNet/sing-box/releases) + * `SFI-iphoneos-arm64.deb`: rootless + * `SFI-iphoneos-arm64e.deb`: roothide -The jailbroken version of SFI: requires rootless iOS 15.0+ +The jailbroken version of SFI: requires rootless or roothide iOS 15.0+ Additional features: From 80c11714105b3771d5ca9c26d7cd0f50c47d196d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 7 Oct 2026 21:13:15 +0800 Subject: [PATCH 62/66] tailscale: Fix SSH auth banners never sent --- protocol/tailscale/tailssh/server.go | 43 +++++++++++++++------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/protocol/tailscale/tailssh/server.go b/protocol/tailscale/tailssh/server.go index 8ba3ba95..6cc49dff 100644 --- a/protocol/tailscale/tailssh/server.go +++ b/protocol/tailscale/tailssh/server.go @@ -252,28 +252,25 @@ func (s *Server) Close() error { } func (s *Server) serverConfig(ctx gliderssh.Context) *gossh.ServerConfig { + var preAuthConn gossh.ServerPreAuthConn config := &gossh.ServerConfig{ + PreAuthConnCallback: func(conn gossh.ServerPreAuthConn) { + preAuthConn = conn + }, NoClientAuthCallback: func(conn gossh.ConnMetadata) (*gossh.Permissions, error) { - return s.authenticate(ctx, conn) + return s.authenticate(ctx, preAuthConn, conn) }, PasswordCallback: func(conn gossh.ConnMetadata, password []byte) (*gossh.Permissions, error) { - return s.authenticate(ctx, conn) + return s.authenticate(ctx, preAuthConn, conn) }, PublicKeyCallback: func(conn gossh.ConnMetadata, key gossh.PublicKey) (*gossh.Permissions, error) { - return s.authenticate(ctx, conn) - }, - BannerCallback: func(conn gossh.ConnMetadata) string { - connInfo := s.connInfoFromContext(ctx) - if connInfo != nil && connInfo.action.Message != "" { - return connInfo.action.Message - } - return "" + return s.authenticate(ctx, preAuthConn, conn) }, } return config } -func (s *Server) authenticate(ctx gliderssh.Context, conn gossh.ConnMetadata) (*gossh.Permissions, error) { +func (s *Server) authenticate(ctx gliderssh.Context, preAuthConn gossh.ServerPreAuthConn, conn gossh.ConnMetadata) (*gossh.Permissions, error) { if s.connInfoFromContext(ctx) != nil { return &gossh.Permissions{}, nil } @@ -298,12 +295,22 @@ func (s *Server) authenticate(ctx gliderssh.Context, conn gossh.ConnMetadata) (* s.logger.Info("SSH auth rejected for ", userProfile.LoginName, " -> ", conn.User(), ": ", err) return nil, &gossh.PartialSuccessError{} } - if connInfo.action.Reject { - s.logger.Info("SSH auth rejected for ", userProfile.LoginName, " -> ", conn.User()) - return nil, &gossh.PartialSuccessError{} - } connInfo.action0 = connInfo.action - for hops := 0; connInfo.action.HoldAndDelegate != ""; hops++ { + for hops := 0; ; hops++ { + if connInfo.action.Message != "" { + err = preAuthConn.SendAuthBanner(connInfo.action.Message) + if err != nil { + s.logger.Info("SSH auth: send banner: ", err) + return nil, &gossh.PartialSuccessError{} + } + } + if connInfo.action.Reject { + s.logger.Info("SSH auth rejected for ", userProfile.LoginName, " -> ", conn.User()) + return nil, &gossh.PartialSuccessError{} + } + if connInfo.action.HoldAndDelegate == "" { + break + } if hops >= 10 { s.logger.Info("SSH auth rejected: hold-and-delegate chain too long") return nil, &gossh.PartialSuccessError{} @@ -314,10 +321,6 @@ func (s *Server) authenticate(ctx gliderssh.Context, conn gossh.ConnMetadata) (* return nil, &gossh.PartialSuccessError{} } connInfo.action = delegatedAction - if connInfo.action.Reject { - s.logger.Info("SSH auth rejected for ", userProfile.LoginName, " -> ", conn.User()) - return nil, &gossh.PartialSuccessError{} - } } if !connInfo.action.Accept { s.logger.Info("SSH auth rejected for ", userProfile.LoginName, " -> ", conn.User()) From 194ebd18b64b673dc0d5da1f3acd26fb3a0a0cd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 7 Oct 2026 23:21:02 +0800 Subject: [PATCH 63/66] tun: Fix inconsistent DNS mode behavior without auto_route --- docs/configuration/inbound/tun.md | 4 +++- docs/configuration/inbound/tun.zh.md | 4 +++- go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/configuration/inbound/tun.md b/docs/configuration/inbound/tun.md index 33653755..2efc548b 100644 --- a/docs/configuration/inbound/tun.md +++ b/docs/configuration/inbound/tun.md @@ -255,7 +255,9 @@ How DNS is handled on the TUN interface. |------------|------------------------------------------------------------------------------------------------------------------------------------------------------------| | `disabled` | Do not configure native DNS and do not hijack DNS traffic. | | `native` | Set the platform's native interface DNS where possible: per-interface DNS on Windows and Apple platforms, and `systemd-resolved` interface DNS on Linux. | -| `hijack` | Same as `native`, with additional port 53 hijacking described below. Used by default. | +| `hijack` | Same as `native`, with additional port 53 hijacking described below. | + +`hijack` is used by default when [`auto_route`](#auto_route) is enabled, otherwise `disabled` is used by default. `hijack` adds the following on top of `native`: diff --git a/docs/configuration/inbound/tun.zh.md b/docs/configuration/inbound/tun.zh.md index 2bbe1a50..a5212e0a 100644 --- a/docs/configuration/inbound/tun.zh.md +++ b/docs/configuration/inbound/tun.zh.md @@ -259,7 +259,9 @@ TUN 接口上 DNS 的处理方式。 |------------|-------------------------------------------------------------------------------------------------------| | `disabled` | 不设置原生 DNS,也不劫持 DNS 流量。 | | `native` | 尽可能设置平台的原生接口 DNS:Windows 与 Apple 上的接口 DNS,Linux 上的 `systemd-resolved` 接口 DNS。 | -| `hijack` | 与 `native` 相同,并额外执行下文所述的 53 端口劫持。默认使用。 | +| `hijack` | 与 `native` 相同,并额外执行下文所述的 53 端口劫持。 | + +启用 [`auto_route`](#auto_route) 时默认使用 `hijack`,否则默认使用 `disabled`。 `hijack` 在 `native` 之上额外执行: diff --git a/go.mod b/go.mod index 263a519a..fdf2cefa 100644 --- a/go.mod +++ b/go.mod @@ -55,7 +55,7 @@ require ( github.com/sagernet/sing-shadowsocks2 v0.2.2-0.20260929152114-a69d1086332b github.com/sagernet/sing-shadowtls v0.2.2-0.20260928201441-a9c0127d5c99 github.com/sagernet/sing-snell v0.0.0-20260904135315-bc5a12ac736f - github.com/sagernet/sing-tun v0.9.7-0.20260929152151-0bdadeb4c934 + github.com/sagernet/sing-tun v0.9.7-0.20261007151649-0e9e4a586ece github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca github.com/sagernet/sing-vmess v0.2.9-0.20260929152519-9b95ab8c9478 github.com/sagernet/smux v1.5.50-sing-box-mod.1 diff --git a/go.sum b/go.sum index 81bbade2..06f15b8e 100644 --- a/go.sum +++ b/go.sum @@ -338,8 +338,8 @@ github.com/sagernet/sing-shadowtls v0.2.2-0.20260928201441-a9c0127d5c99 h1:l/tdZ github.com/sagernet/sing-shadowtls v0.2.2-0.20260928201441-a9c0127d5c99/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= github.com/sagernet/sing-snell v0.0.0-20260904135315-bc5a12ac736f h1:tbTCI+iis5sTmua7a6Q7vebcSRURok/LGgnEafnQAsc= github.com/sagernet/sing-snell v0.0.0-20260904135315-bc5a12ac736f/go.mod h1:et8Lws4f5QbOrY65DmjevHGup3mijJkhswkto6cwciM= -github.com/sagernet/sing-tun v0.9.7-0.20260929152151-0bdadeb4c934 h1:CC1NMvo1hpRY33IJCwXldUOSgCLjYh0T4vq9pX30+tM= -github.com/sagernet/sing-tun v0.9.7-0.20260929152151-0bdadeb4c934/go.mod h1:3EgPst7agntRO7D6GOsiZ1l9FoqdLeuWmKT5TnWkmf0= +github.com/sagernet/sing-tun v0.9.7-0.20261007151649-0e9e4a586ece h1://BO1bRUuh7JjQ2NFRvXt+A2VXd1mBbp5G2lhv5fCag= +github.com/sagernet/sing-tun v0.9.7-0.20261007151649-0e9e4a586ece/go.mod h1:3EgPst7agntRO7D6GOsiZ1l9FoqdLeuWmKT5TnWkmf0= github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca h1:5wA+IE0Fq1CGVLOgSpm0gKKZ03HzugcJ2JyhmDqeX6A= github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca/go.mod h1:ADAZZU85MnM91XNhd2TdZRTaIbA7RjTZuQ1KCHcUbNg= github.com/sagernet/sing-vmess v0.2.9-0.20260929152519-9b95ab8c9478 h1:q2eQn4nq8oWGxRm9zXesXSlPr9GEgPrOGzf4iPtzG9A= From 1a773a425a8ee45a11cbfb2c4333b330e593b6c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 8 Oct 2026 12:49:20 +0800 Subject: [PATCH 64/66] Exclude USB/IP from legacy macOS build --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c9a32960..812d8701 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -525,7 +525,7 @@ jobs: if [[ "${{ matrix.legacy_osx }}" != "true" ]]; then TAGS=$(cat release/DEFAULT_BUILD_TAGS) else - TAGS=$(cat release/DEFAULT_BUILD_TAGS_OTHERS) + TAGS=$(sed 's/,with_usbip//' release/DEFAULT_BUILD_TAGS_OTHERS) fi echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}" - name: Set shared ldflags From 78d44d52d9599770bf2b30351982a7339aa86481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 9 Oct 2026 10:02:50 +0800 Subject: [PATCH 65/66] Reset network on DNS server changes --- .../local/systemconfig/source_darwin.go | 292 +----------------- experimental/libbox/monitor.go | 71 ++++- go.mod | 2 +- go.sum | 4 +- 4 files changed, 88 insertions(+), 281 deletions(-) diff --git a/dns/transport/local/systemconfig/source_darwin.go b/dns/transport/local/systemconfig/source_darwin.go index e8bda779..38d74848 100644 --- a/dns/transport/local/systemconfig/source_darwin.go +++ b/dns/transport/local/systemconfig/source_darwin.go @@ -2,129 +2,14 @@ package systemconfig -/* -#include -#include -#include -#include -#include -#include - -// dnsinfo.h is not shipped in any SDK. The layouts below are DNSINFO_VERSION -// 20170629 from apple-oss-distributions/configd (#pragma pack(4)), the format -// libsystem_configuration unpacks into at runtime. dns_configuration_copy, -// dns_configuration_free and dns_configuration_notify_key are private -// libSystem exports. cgo silently drops packed struct fields that fall on -// unaligned offsets. - -#pragma pack(4) -typedef struct { - struct in_addr address; - struct in_addr mask; -} box_dns_sortaddr_t; - -typedef struct { - char *domain; - int32_t n_nameserver; - struct sockaddr **nameserver; - uint16_t port; - int32_t n_search; - char **search; - int32_t n_sortaddr; - box_dns_sortaddr_t **sortaddr; - char *options; - uint32_t timeout; - uint32_t search_order; - uint32_t if_index; - uint32_t flags; - uint32_t reach_flags; - uint32_t service_identifier; - char *cid; - char *if_name; -} box_dns_resolver_t; - -typedef struct { - int32_t n_resolver; - box_dns_resolver_t **resolver; - int32_t n_scoped_resolver; - box_dns_resolver_t **scoped_resolver; - uint64_t generation; - int32_t n_service_specific_resolver; - box_dns_resolver_t **service_specific_resolver; - uint32_t version; -} box_dns_config_t; -#pragma pack() - -static box_dns_config_t *(*box_dns_configuration_copy)(void); -static void (*box_dns_configuration_free)(box_dns_config_t *); - -static void box_reverse_string(char *s) { - size_t length = strlen(s); - for (size_t i = 0; i < length / 2; i++) { - char tmp = s[i]; - s[i] = s[length - 1 - i]; - s[length - 1 - i] = tmp; - } -} - -static int box_dnsinfo_load(void) { - if (box_dns_configuration_copy != NULL && box_dns_configuration_free != NULL) { - return 1; - } - char copy_name[] = "ypoc_noitarugifnoc_snd"; - char free_name[] = "eerf_noitarugifnoc_snd"; - box_reverse_string(copy_name); - box_reverse_string(free_name); - box_dns_configuration_copy = (box_dns_config_t * (*)(void)) dlsym(RTLD_DEFAULT, copy_name); - box_dns_configuration_free = (void (*)(box_dns_config_t *))dlsym(RTLD_DEFAULT, free_name); - return box_dns_configuration_copy != NULL && box_dns_configuration_free != NULL; -} - -static box_dns_config_t *box_dnsinfo_copy(void) { - return box_dns_configuration_copy(); -} - -static void box_dnsinfo_free(box_dns_config_t *config) { - box_dns_configuration_free(config); -} - -static const char *box_dnsinfo_notify_key(void) { - const char *(*notify_key)(void) = (const char *(*)(void))dlsym(RTLD_DEFAULT, "dns_configuration_notify_key"); - if (notify_key != NULL) { - return notify_key(); - } - return "com.apple.system.SystemConfiguration.dns_configuration"; -} - -static box_dns_resolver_t *box_dnsinfo_default_resolver(box_dns_config_t *config, int32_t index) { - return config->resolver[index]; -} - -static box_dns_resolver_t *box_dnsinfo_scoped_resolver(box_dns_config_t *config, int32_t index) { - return config->scoped_resolver[index]; -} - -static struct sockaddr *box_dnsinfo_nameserver(box_dns_resolver_t *resolver, int32_t index) { - return resolver->nameserver[index]; -} - -static const char *box_dnsinfo_search_domain(box_dns_resolver_t *resolver, int32_t index) { - return resolver->search[index]; -} -*/ -import "C" - import ( "context" - "encoding/binary" - "net/netip" - "strconv" "sync" "time" - "unsafe" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/dnsinfo" "github.com/sagernet/sing/common" M "github.com/sagernet/sing/common/metadata" "github.com/sagernet/sing/service" @@ -135,25 +20,15 @@ import ( type Source struct { interfaceMonitor tun.DefaultInterfaceMonitor access sync.Mutex - notifyToken C.int - notifyValid bool stale bool interfaceIndex int config *Config } func NewSource(ctx context.Context) *Source { - source := &Source{ + return &Source{ interfaceMonitor: service.FromContext[adapter.NetworkManager](ctx).InterfaceMonitor(), } - if C.box_dnsinfo_load() != 0 { - var token C.int - if C.notify_register_check(C.box_dnsinfo_notify_key(), &token) == 0 { - source.notifyToken = token - source.notifyValid = true - } - } - return source } func (s *Source) Configuration() *Config { @@ -162,19 +37,18 @@ func (s *Source) Configuration() *Config { defer s.access.Unlock() interfaceChanged := s.interfaceIndex != interfaceIndex s.interfaceIndex = interfaceIndex - changed := s.changedLocked() - if s.config != nil && !s.stale && !interfaceChanged && !changed { + if s.config != nil && !s.stale && !interfaceChanged && s.interfaceMonitor != nil { return s.config } s.stale = false - systemInfo := copyDNSInfo() - if systemInfo == nil { + dnsConfiguration := dnsinfo.Copy() + if dnsConfiguration == nil { if s.config == nil { - s.config = new(dnsInfoConfig).build(interfaceIndex) + s.config = newConfig(dnsinfo.Resolver{}) } return s.config } - config := systemInfo.build(interfaceIndex) + config := newConfig(dnsConfiguration.Select(interfaceIndex)) if s.config != nil && config.Equal(s.config) { return s.config } @@ -182,18 +56,6 @@ func (s *Source) Configuration() *Config { return config } -func (s *Source) changedLocked() bool { - if !s.notifyValid { - return true - } - var changed C.int - status := C.notify_check(s.notifyToken, &changed) - if status != 0 { - return true - } - return changed != 0 -} - func (s *Source) Reset() { s.access.Lock() s.stale = true @@ -201,12 +63,6 @@ func (s *Source) Reset() { } func (s *Source) Close() error { - s.access.Lock() - defer s.access.Unlock() - if s.notifyValid { - C.notify_cancel(s.notifyToken) - s.notifyValid = false - } return nil } @@ -221,144 +77,30 @@ func (s *Source) defaultInterfaceIndex() int { return defaultInterface.Index } -type dnsInfoResolver struct { - interfaceIndex int - domain string - servers []M.Socksaddr - search []string - timeout time.Duration -} - -type dnsInfoConfig struct { - resolvers []dnsInfoResolver - scopedResolvers []dnsInfoResolver -} - -func (c *dnsInfoConfig) build(interfaceIndex int) *Config { - var selected dnsInfoResolver - if interfaceIndex != 0 { - selected = common.Find(c.scopedResolvers, func(it dnsInfoResolver) bool { - return it.interfaceIndex == interfaceIndex && len(it.servers) > 0 - }) - } - if len(selected.servers) == 0 { - selected = common.Find(c.resolvers, func(it dnsInfoResolver) bool { - return it.domain == "" && len(it.servers) > 0 - }) - } +func newConfig(resolver dnsinfo.Resolver) *Config { config := &Config{ Ndots: 1, Timeout: 5 * time.Second, Attempts: 2, } - if len(selected.servers) == 0 { + if len(resolver.Servers) == 0 { config.Servers = defaultServers config.Search = defaultSearch() return config } - config.Servers = selected.servers - if len(selected.search) > 0 { - config.Search = selected.search - } else { - config.Search = defaultSearch() - } - if selected.timeout > 0 { - config.Timeout = selected.timeout - } - return config -} - -func copyDNSInfo() *dnsInfoConfig { - if C.box_dnsinfo_load() == 0 { - return nil - } - rawConfig := C.box_dnsinfo_copy() - if rawConfig == nil { - return nil - } - defer C.box_dnsinfo_free(rawConfig) - systemInfo := new(dnsInfoConfig) - for i := C.int32_t(0); i < rawConfig.n_resolver; i++ { - rawResolver := C.box_dnsinfo_default_resolver(rawConfig, i) - if rawResolver == nil { - continue - } - systemInfo.resolvers = append(systemInfo.resolvers, parseResolver(rawResolver)) - } - for i := C.int32_t(0); i < rawConfig.n_scoped_resolver; i++ { - rawResolver := C.box_dnsinfo_scoped_resolver(rawConfig, i) - if rawResolver == nil { - continue - } - systemInfo.scopedResolvers = append(systemInfo.scopedResolvers, parseResolver(rawResolver)) - } - return systemInfo -} - -func parseResolver(rawResolver *C.box_dns_resolver_t) dnsInfoResolver { - resolver := dnsInfoResolver{ - interfaceIndex: int(rawResolver.if_index), - domain: C.GoString(rawResolver.domain), - timeout: time.Duration(rawResolver.timeout) * time.Second, - } - interfaceName := C.GoString(rawResolver.if_name) - resolverPort := uint16(rawResolver.port) - if resolverPort == 0 { - resolverPort = 53 - } - for i := C.int32_t(0); i < rawResolver.n_nameserver; i++ { - rawSockaddr := C.box_dnsinfo_nameserver(rawResolver, i) - if rawSockaddr == nil { - continue - } - serverAddr, loaded := parseSockaddr(rawSockaddr, resolverPort, interfaceName) - if !loaded { - continue - } - resolver.servers = append(resolver.servers, M.SocksaddrFromNetIP(serverAddr)) - } - for i := C.int32_t(0); i < rawResolver.n_search; i++ { - searchDomain := C.GoString(C.box_dnsinfo_search_domain(rawResolver, i)) - if searchDomain == "" { - continue - } + config.Servers = common.Map(resolver.Servers, M.SocksaddrFromNetIP) + for _, searchDomain := range resolver.Search { searchDomain = mDNS.Fqdn(searchDomain) if searchDomain == "." { continue } - resolver.search = append(resolver.search, searchDomain) + config.Search = append(config.Search, searchDomain) } - return resolver -} - -func parseSockaddr(rawSockaddr *C.struct_sockaddr, fallbackPort uint16, zone string) (netip.AddrPort, bool) { - switch rawSockaddr.sa_family { - case C.AF_INET: - sockaddrInet := (*C.struct_sockaddr_in)(unsafe.Pointer(rawSockaddr)) - addr := netip.AddrFrom4(*(*[4]byte)(unsafe.Pointer(&sockaddrInet.sin_addr))) - return netip.AddrPortFrom(addr, sockaddrPort(unsafe.Pointer(&sockaddrInet.sin_port), fallbackPort)), true - case C.AF_INET6: - sockaddrInet6 := (*C.struct_sockaddr_in6)(unsafe.Pointer(rawSockaddr)) - addr := netip.AddrFrom16(*(*[16]byte)(unsafe.Pointer(&sockaddrInet6.sin6_addr))) - if addr.IsLinkLocalUnicast() { - scopeId := uint32(sockaddrInet6.sin6_scope_id) - if zone == "" && scopeId != 0 { - zone = strconv.FormatUint(uint64(scopeId), 10) - } - if zone != "" { - addr = addr.WithZone(zone) - } - } - return netip.AddrPortFrom(addr, sockaddrPort(unsafe.Pointer(&sockaddrInet6.sin6_port), fallbackPort)), true - default: - return netip.AddrPort{}, false + if len(config.Search) == 0 { + config.Search = defaultSearch() } -} - -func sockaddrPort(rawPort unsafe.Pointer, fallbackPort uint16) uint16 { - port := binary.BigEndian.Uint16((*[2]byte)(rawPort)[:]) - if port == 0 { - return fallbackPort + if resolver.Timeout > 0 { + config.Timeout = resolver.Timeout } - return port + return config } diff --git a/experimental/libbox/monitor.go b/experimental/libbox/monitor.go index 1fe21d72..e42ea17d 100644 --- a/experimental/libbox/monitor.go +++ b/experimental/libbox/monitor.go @@ -1,8 +1,15 @@ package libbox import ( + "net/netip" + "slices" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/service/powerreport" tun "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/dnsinfo" + "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/control" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" @@ -20,15 +27,35 @@ type platformDefaultInterfaceMonitor struct { callbacks list.List[tun.DefaultInterfaceUpdateCallback] myInterfaces []string defaultInterfaceInitialized bool + defaultDNSServers []string + dnsWatcher *dnsinfo.Watcher lastNetworkPath string } func (m *platformDefaultInterfaceMonitor) Start() error { - return m.iif.StartDefaultInterfaceMonitor(m) + if C.IsDarwin { + dnsWatcher, err := dnsinfo.NewWatcher(m.updateDNSServers, m.logger) + if err != nil { + return E.Cause(err, "watch DNS configuration") + } + m.dnsWatcher = dnsWatcher + } + err := m.iif.StartDefaultInterfaceMonitor(m) + if err != nil { + if C.IsDarwin { + m.dnsWatcher.Close() + } + return err + } + return nil } func (m *platformDefaultInterfaceMonitor) Close() error { - return m.iif.CloseDefaultInterfaceMonitor(m) + err := m.iif.CloseDefaultInterfaceMonitor(m) + if C.IsDarwin { + err = E.Errors(err, m.dnsWatcher.Close()) + } + return err } func (m *platformDefaultInterfaceMonitor) DefaultInterface() *control.Interface { @@ -113,6 +140,7 @@ func (m *platformDefaultInterfaceMonitor) updateDefaultInterface(interfaceName s m.defaultInterfaceAccess.Lock() if interfaceIndex32 == -1 { m.defaultInterface = nil + m.defaultDNSServers = nil m.defaultInterfaceInitialized = true callbacks := m.callbacks.Array() m.defaultInterfaceAccess.Unlock() @@ -128,8 +156,10 @@ func (m *platformDefaultInterfaceMonitor) updateDefaultInterface(interfaceName s m.logger.Error(E.Cause(err, "find updated interface: ", interfaceName)) return } + oldDNSServers := m.defaultDNSServers m.defaultInterface = newInterface - if m.defaultInterfaceInitialized && oldInterface != nil && oldInterface.Name == m.defaultInterface.Name && oldInterface.Index == m.defaultInterface.Index { + m.defaultDNSServers = m.readDNSServers(newInterface.Index) + if m.defaultInterfaceInitialized && oldInterface != nil && oldInterface.Name == m.defaultInterface.Name && oldInterface.Index == m.defaultInterface.Index && slices.Equal(oldDNSServers, m.defaultDNSServers) { m.defaultInterfaceAccess.Unlock() return } @@ -141,6 +171,41 @@ func (m *platformDefaultInterfaceMonitor) updateDefaultInterface(interfaceName s } } +func (m *platformDefaultInterfaceMonitor) updateDNSServers() { + m.defaultInterfaceAccess.Lock() + defaultInterface := m.defaultInterface + if defaultInterface == nil { + m.defaultInterfaceAccess.Unlock() + return + } + dnsServers := m.readDNSServers(defaultInterface.Index) + if slices.Equal(m.defaultDNSServers, dnsServers) { + m.defaultInterfaceAccess.Unlock() + return + } + m.defaultDNSServers = dnsServers + callbacks := m.callbacks.Array() + m.defaultInterfaceAccess.Unlock() + for _, callback := range callbacks { + callback(defaultInterface, 0) + } +} + +func (m *platformDefaultInterfaceMonitor) readDNSServers(interfaceIndex int) []string { + if C.IsDarwin { + dnsConfiguration := dnsinfo.Copy() + if dnsConfiguration == nil { + return nil + } + return common.Map(dnsConfiguration.Select(interfaceIndex).Servers, func(it netip.AddrPort) string { + return it.Addr().String() + }) + } + return common.Find(m.networkManager.NetworkInterfaces(), func(it adapter.NetworkInterface) bool { + return it.Index == interfaceIndex + }).DNSServers +} + func (m *platformDefaultInterfaceMonitor) RegisterMyInterface(interfaceName string) { m.defaultInterfaceAccess.Lock() defer m.defaultInterfaceAccess.Unlock() diff --git a/go.mod b/go.mod index fdf2cefa..53b8a1ca 100644 --- a/go.mod +++ b/go.mod @@ -55,7 +55,7 @@ require ( github.com/sagernet/sing-shadowsocks2 v0.2.2-0.20260929152114-a69d1086332b github.com/sagernet/sing-shadowtls v0.2.2-0.20260928201441-a9c0127d5c99 github.com/sagernet/sing-snell v0.0.0-20260904135315-bc5a12ac736f - github.com/sagernet/sing-tun v0.9.7-0.20261007151649-0e9e4a586ece + github.com/sagernet/sing-tun v0.9.7-0.20261009020158-bca4bf029237 github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca github.com/sagernet/sing-vmess v0.2.9-0.20260929152519-9b95ab8c9478 github.com/sagernet/smux v1.5.50-sing-box-mod.1 diff --git a/go.sum b/go.sum index 06f15b8e..ed70f587 100644 --- a/go.sum +++ b/go.sum @@ -338,8 +338,8 @@ github.com/sagernet/sing-shadowtls v0.2.2-0.20260928201441-a9c0127d5c99 h1:l/tdZ github.com/sagernet/sing-shadowtls v0.2.2-0.20260928201441-a9c0127d5c99/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= github.com/sagernet/sing-snell v0.0.0-20260904135315-bc5a12ac736f h1:tbTCI+iis5sTmua7a6Q7vebcSRURok/LGgnEafnQAsc= github.com/sagernet/sing-snell v0.0.0-20260904135315-bc5a12ac736f/go.mod h1:et8Lws4f5QbOrY65DmjevHGup3mijJkhswkto6cwciM= -github.com/sagernet/sing-tun v0.9.7-0.20261007151649-0e9e4a586ece h1://BO1bRUuh7JjQ2NFRvXt+A2VXd1mBbp5G2lhv5fCag= -github.com/sagernet/sing-tun v0.9.7-0.20261007151649-0e9e4a586ece/go.mod h1:3EgPst7agntRO7D6GOsiZ1l9FoqdLeuWmKT5TnWkmf0= +github.com/sagernet/sing-tun v0.9.7-0.20261009020158-bca4bf029237 h1:B9tUcRf1FAatPyzN8sKEvIHMvKNkFMBZikeIOL4gqtQ= +github.com/sagernet/sing-tun v0.9.7-0.20261009020158-bca4bf029237/go.mod h1:3EgPst7agntRO7D6GOsiZ1l9FoqdLeuWmKT5TnWkmf0= github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca h1:5wA+IE0Fq1CGVLOgSpm0gKKZ03HzugcJ2JyhmDqeX6A= github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca/go.mod h1:ADAZZU85MnM91XNhd2TdZRTaIbA7RjTZuQ1KCHcUbNg= github.com/sagernet/sing-vmess v0.2.9-0.20260929152519-9b95ab8c9478 h1:q2eQn4nq8oWGxRm9zXesXSlPr9GEgPrOGzf4iPtzG9A= From 7054cac5124465ffdb145870d7c01885566711b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 9 Oct 2026 11:16:24 +0800 Subject: [PATCH 66/66] Bump version --- clients/android | 2 +- clients/apple | 2 +- clients/desktop | 2 +- docs/changelog.md | 4 ++++ 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/clients/android b/clients/android index fc21909d..ae93152f 160000 --- a/clients/android +++ b/clients/android @@ -1 +1 @@ -Subproject commit fc21909df7a3f0fc9435f3866fb6a4960711aa5f +Subproject commit ae93152f21457723af77d144f28c1f7be7362aec diff --git a/clients/apple b/clients/apple index 9f828a26..e232aaf2 160000 --- a/clients/apple +++ b/clients/apple @@ -1 +1 @@ -Subproject commit 9f828a26c8ef437ffc0660693acf794a1649bd5e +Subproject commit e232aaf22d6d68773196ffc8e6e79684a6be717c diff --git a/clients/desktop b/clients/desktop index 32f915ba..50f50384 160000 --- a/clients/desktop +++ b/clients/desktop @@ -1 +1 @@ -Subproject commit 32f915ba595601dbc2dd346c33fe9fedd3e72979 +Subproject commit 50f503849148dd73fa4f4bc231119acf5b2f3d7a diff --git a/docs/changelog.md b/docs/changelog.md index b58fc55b..15668745 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,10 @@ icon: material/alert-decagram --- +#### 1.14.3 + +* Fixes and improvements + #### 1.14.2 * Fixes and improvements