diff --git a/.github/CRONET_GO_VERSION b/.github/CRONET_GO_VERSION index 95a68fba..ff9f0d16 100644 --- a/.github/CRONET_GO_VERSION +++ b/.github/CRONET_GO_VERSION @@ -1 +1 @@ -45832ab074849607406baa3e3a2c4660274602ed +0d28acc44093df24b2526dea3d6ffefd6b0a54f0 diff --git a/.github/go_ios_cpu_features.patch b/.github/go_ios_cpu_features.patch new file mode 100644 index 00000000..704d2d76 --- /dev/null +++ b/.github/go_ios_cpu_features.patch @@ -0,0 +1,28 @@ +--- a/src/internal/cpu/cpu_arm64_ios.go ++++ b/src/internal/cpu/cpu_arm64_ios.go +@@ -0,0 +1,14 @@ ++// Copyright 2020 The Go Authors. All rights reserved. ++// Use of this source code is governed by a BSD-style ++// license that can be found in the LICENSE file. ++ ++//go:build arm64 && ios ++ ++package cpu ++ ++func osInit() { ++ ARM64.HasAES = true ++ ARM64.HasPMULL = true ++ ARM64.HasSHA1 = true ++ ARM64.HasSHA2 = true ++} +--- a/src/internal/cpu/cpu_arm64_other.go ++++ b/src/internal/cpu/cpu_arm64_other.go +@@ -2,7 +2,7 @@ + // Use of this source code is governed by a BSD-style + // license that can be found in the LICENSE file. + +-//go:build arm64 && !linux && !freebsd && !android && (!darwin || ios) && !openbsd ++//go:build arm64 && !linux && !freebsd && !android && !darwin && !openbsd + + package cpu + diff --git a/.github/patch_go_for_ios.sh b/.github/patch_go_for_ios.sh new file mode 100755 index 00000000..6105dd50 --- /dev/null +++ b/.github/patch_go_for_ios.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Go's internal/cpu never detects ARM64 features on GOOS=ios, so crypto/aes, +# AES-GCM and crypto/sha256 use their generic implementations on iOS and tvOS. +# See https://github.com/SagerNet/sing-box/issues/4486 +# +# Only the ARMv8.0 cryptographic extensions are asserted; ARMv8.1 atomics and +# SHA-512 are absent on the A8/A9 devices still supported by the deployment targets. +# +# Remove once the Go release used by the Apple library build includes the fix. + +export GOTOOLCHAIN=local + +GOROOT="$(go env GOROOT)" +PATCH_FILE="$(cd "$(dirname "$0")" && pwd)/go_ios_cpu_features.patch" + +cd "$GOROOT" +if [[ -f src/internal/cpu/cpu_arm64_ios.go ]]; then + echo "already patched" +else + patch --verbose -p1 < "$PATCH_FILE" +fi + +CPU_FILES="$(GOOS=ios GOARCH=arm64 CGO_ENABLED=0 go list -f '{{.GoFiles}}' internal/cpu)" +echo "internal/cpu files for ios/arm64: $CPU_FILES" +case "$CPU_FILES" in +*cpu_arm64_ios.go*) ;; +*) + echo "patch is not effective" >&2 + exit 1 + ;; +esac diff --git a/.github/setup_go_for_macos1013.sh b/.github/setup_go_for_macos1013.sh index b2668c89..b37543fd 100755 --- a/.github/setup_go_for_macos1013.sh +++ b/.github/setup_go_for_macos1013.sh @@ -2,7 +2,7 @@ set -euo pipefail -VERSION="1.26.7" +VERSION="1.26.8" PATCH_COMMITS=( "f080b0c6346eb690c0dc82497b35925f385b35ac" "2d9c12887c342fb9051d231aa5388743cb7e9cb6" diff --git a/.github/setup_go_for_windows7.sh b/.github/setup_go_for_windows7.sh index 1256c69a..f3065a34 100755 --- a/.github/setup_go_for_windows7.sh +++ b/.github/setup_go_for_windows7.sh @@ -2,7 +2,7 @@ set -euo pipefail -VERSION="1.26.7" +VERSION="1.26.8" PATCH_COMMITS=( "a4ae550aa148b04c9d4890e98bee63aede5c4b53" "95b851f661584711faa8115b3234a461044f4510" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b30ac60d..049399e4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,7 +54,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: 1.26.7 + go-version: 1.26.8 cache: false - name: Cache Go modules uses: actions/cache/restore@v4 @@ -145,7 +145,7 @@ jobs: if: ${{ ! matrix.legacy_win7 }} uses: actions/setup-go@v5 with: - go-version: 1.26.7 + go-version: 1.26.8 cache: false - name: Cache Go modules uses: actions/cache/restore@v4 @@ -160,7 +160,7 @@ jobs: with: path: | ~/go/go_win7 - key: go_win7_1267 + key: go_win7_1268 - name: Setup Go for Windows 7 if: matrix.legacy_win7 && steps.cache-go-for-windows7.outputs.cache-hit != 'true' env: @@ -488,7 +488,7 @@ jobs: if: ${{ ! matrix.legacy_osx }} uses: actions/setup-go@v5 with: - go-version: 1.26.7 + go-version: 1.26.8 cache: false - name: Cache Go modules uses: actions/cache/restore@v4 @@ -503,7 +503,7 @@ jobs: with: path: | ~/go/go_osx - key: go_osx_1267 + key: go_osx_1268 - name: Setup Go for macOS 10.13 if: matrix.legacy_osx && steps.cache-go-for-macos1013.outputs.cache-hit != 'true' env: @@ -587,7 +587,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: 1.26.7 + go-version: 1.26.8 cache: false - name: Cache Go modules uses: actions/cache@v4 @@ -690,7 +690,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: 1.26.7 + go-version: 1.26.8 cache: false - name: Cache Go modules uses: actions/cache@v4 @@ -754,7 +754,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: 1.26.7 + go-version: 1.26.8 cache: false - name: Cache Go modules uses: actions/cache/restore@v4 @@ -862,7 +862,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: 1.26.7 + go-version: 1.26.8 cache: false - name: Cache Go modules uses: actions/cache/restore@v4 @@ -975,7 +975,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: 1.26.7 + go-version: 1.26.8 cache: false - name: Cache Go modules uses: actions/cache@v4 @@ -1087,7 +1087,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: 1.26.7 + go-version: 1.26.8 cache: false - name: Cache Go modules uses: actions/cache@v4 @@ -1244,8 +1244,11 @@ jobs: if: matrix.build uses: actions/setup-go@v5 with: - go-version: 1.26.7 + go-version: 1.26.8 cache: false + - name: Patch Go for iOS + if: matrix.build && (matrix.platform == 'ios/arm64' || matrix.platform == 'tvos/arm64') + run: .github/patch_go_for_ios.sh - name: Cache Go modules if: matrix.build uses: actions/cache@v4 @@ -1258,13 +1261,15 @@ jobs: uses: actions/cache@v4 with: path: ~/Library/Caches/go-build - key: libbox-go-build-${{ matrix.artifact }}-1.26.7-${{ hashFiles('go.sum') }} - restore-keys: libbox-go-build-${{ matrix.artifact }}-1.26.7- + key: libbox-go-build-${{ matrix.artifact }}-1.26.8-${{ hashFiles('go.sum') }} + restore-keys: libbox-go-build-${{ matrix.artifact }}-1.26.8- - name: Set tag if: matrix.build run: git tag v${{ needs.calculate_version.outputs.version }} -f - name: Build library if: matrix.build + env: + GOTOOLCHAIN: local run: |- make lib_install export PATH="$PATH:$(go env GOPATH)/bin" @@ -1326,7 +1331,7 @@ jobs: if: matrix.if uses: actions/setup-go@v5 with: - go-version: 1.26.7 + go-version: 1.26.8 cache: false - name: Cache Go modules if: matrix.if @@ -1370,10 +1375,10 @@ jobs: uses: actions/cache@v4 with: path: ${{ matrix.type == 'standalone' && 'clients/apple/build/SFM.System-*.dd' || matrix.type == 'jailbreak' && 'clients/apple/build/jailbreak/DerivedData' || format('{0}/DerivedData', runner.temp) }} - key: apple-derived-data-${{ matrix.type }}-${{ matrix.platform }}-${{ matrix.variant || 'default' }}-${{ hashFiles('clients/apple/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}-${{ github.sha }} + key: apple-derived-data-${{ matrix.type }}-${{ matrix.platform }}-${{ matrix.variant || 'default' }}-${{ github.ref_name }}-${{ hashFiles('clients/apple/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}-${{ github.sha }} restore-keys: | - apple-derived-data-${{ matrix.type }}-${{ matrix.platform }}-${{ matrix.variant || 'default' }}-${{ hashFiles('clients/apple/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}- - apple-derived-data-${{ matrix.type }}-${{ matrix.platform }}-${{ matrix.variant || 'default' }}- + apple-derived-data-${{ matrix.type }}-${{ matrix.platform }}-${{ matrix.variant || 'default' }}-${{ github.ref_name }}-${{ hashFiles('clients/apple/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}- + apple-derived-data-${{ matrix.type }}-${{ matrix.platform }}-${{ matrix.variant || 'default' }}-${{ github.ref_name }}- - name: Setup App Store Connect key if: matrix.if && matrix.type != 'jailbreak' && github.event_name == 'workflow_dispatch' run: |- diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index c5e1778b..d525f4f0 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -55,7 +55,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: 1.26.7 + go-version: 1.26.8 - name: Clone cronet-go if: matrix.naive run: | diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index fd2ba7b3..985c787d 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -29,7 +29,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: 1.26.7 + go-version: 1.26.8 - name: Check input version if: github.event_name == 'workflow_dispatch' run: |- @@ -72,7 +72,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: 1.26.7 + go-version: 1.26.8 - name: Clone cronet-go if: matrix.naive run: | diff --git a/adapter/experimental.go b/adapter/experimental.go index 9d84fe1e..fef6a037 100644 --- a/adapter/experimental.go +++ b/adapter/experimental.go @@ -8,18 +8,9 @@ import ( "time" E "github.com/sagernet/sing/common/exceptions" - "github.com/sagernet/sing/common/observable" "github.com/sagernet/sing/common/varbin" ) -type ClashServer interface { - LifecycleService - Mode() string - ModeList() []string - SetMode(mode string) - AddModeUpdateHook(hook *observable.Subscriber[struct{}]) -} - type URLTestHistory struct { Time time.Time `json:"time"` Delay uint16 `json:"delay"` diff --git a/adapter/router.go b/adapter/router.go index 1de092b1..be626741 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -93,7 +93,11 @@ func JudgeFlow(router Router, inbound string, inboundType string, network uint8, case PreMatchDrop: return tun.FlowVerdict{Action: tun.ActionDrop} case PreMatchBypass: - return tun.FlowVerdict{Action: tun.ActionBypass} + port, isPort := result.Outbound.(tun.Port) + if !isPort { + return tun.FlowVerdict{Action: tun.ActionBypass} + } + return tun.FlowVerdict{Action: tun.ActionBypass, Port: port, UDPTimeout: result.UDPTimeout, NewTracker: result.NewTracker} case PreMatchHijackDNS: return tun.FlowVerdict{Action: tun.ActionHijackDNS} default: diff --git a/box.go b/box.go index 2797f5a1..7c486927 100644 --- a/box.go +++ b/box.go @@ -28,6 +28,7 @@ import ( "github.com/sagernet/sing-box/dns" "github.com/sagernet/sing-box/experimental" "github.com/sagernet/sing-box/experimental/cachefile" + "github.com/sagernet/sing-box/experimental/clashmode" "github.com/sagernet/sing-box/experimental/deprecated" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" @@ -169,7 +170,7 @@ func New(options Options) (*Box, error) { if experimentalOptions.CacheFile != nil && experimentalOptions.CacheFile.Enabled || options.PlatformLogWriter != nil { needCacheFile = true } - if experimentalOptions.ClashAPI != nil || options.PlatformLogWriter != nil { + if experimentalOptions.ClashAPI != nil { needClashAPI = true } if experimentalOptions.V2RayAPI != nil && experimentalOptions.V2RayAPI.Listen != "" { @@ -192,7 +193,7 @@ func New(options Options) (*Box, error) { logFactory, err := log.New(log.Options{ Context: ctx, Options: common.PtrValueOrDefault(options.Log), - Observable: needClashAPI || needAPIService, + Observable: needClashAPI && experimentalOptions.ClashAPI.ExternalController != "", DefaultWriter: defaultLogWriter, BaseTime: createdAt, PlatformWriter: options.PlatformLogWriter, @@ -260,11 +261,18 @@ func New(options Options) (*Box, error) { if err != nil { return nil, E.Cause(err, "initialize router") } - if needClashAPI || needAPIService { + if needClashAPI || needAPIService || options.PlatformLogWriter != nil { trafficManager := trafficcontrol.NewManager(outboundManager) service.MustRegisterPtr(ctx, trafficManager) router.AppendTracker(trafficManager) internalServices = append(internalServices, trafficManager) + var clashDefaultMode string + if experimentalOptions.ClashAPI != nil { + clashDefaultMode = experimentalOptions.ClashAPI.DefaultMode + } + clashMode := clashmode.NewManager(ctx, logFactory.NewLogger("clash-mode"), clashDefaultMode, clashmode.CalculateModeList(options.Options)) + service.MustRegisterPtr(ctx, clashMode) + internalServices = append(internalServices, clashMode) } ntpOptions := common.PtrValueOrDefault(options.NTP) var timeService *tls.TimeServiceWrapper @@ -460,13 +468,10 @@ func New(options Options) (*Box, error) { internalServices = append(internalServices, cacheFile) } if needClashAPI { - clashAPIOptions := common.PtrValueOrDefault(experimentalOptions.ClashAPI) - clashAPIOptions.ModeList = experimental.CalculateClashModeList(options.Options) - clashServer, err := experimental.NewClashServer(ctx, logFactory.(log.ObservableFactory), clashAPIOptions) + clashServer, err := experimental.NewClashServer(ctx, logFactory.(log.ObservableFactory), common.PtrValueOrDefault(experimentalOptions.ClashAPI)) if err != nil { return nil, E.Cause(err, "create clash-server") } - service.MustRegister[adapter.ClashServer](ctx, clashServer) internalServices = append(internalServices, clashServer) } if needV2RayAPI { diff --git a/clients/android b/clients/android deleted file mode 160000 index eb872169..00000000 --- a/clients/android +++ /dev/null @@ -1 +0,0 @@ -Subproject commit eb87216961321de1802e1355c470242f2ed5faa8 diff --git a/clients/apple b/clients/apple deleted file mode 160000 index c19945f6..00000000 --- a/clients/apple +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c19945f65be76ae5d16fc684a166079877802641 diff --git a/clients/desktop b/clients/desktop index cebee0d5..fb1175ca 160000 --- a/clients/desktop +++ b/clients/desktop @@ -1 +1 @@ -Subproject commit cebee0d527c4e5d5500f971553628e0dfa8bae0f +Subproject commit fb1175ca3b00bfc676c4b4b2b25f5eacb6a0c7ae diff --git a/cmd/internal/build_shared/tag.go b/cmd/internal/build_shared/tag.go index a4971a23..b0287abd 100644 --- a/cmd/internal/build_shared/tag.go +++ b/cmd/internal/build_shared/tag.go @@ -39,5 +39,5 @@ func ReadTagVersion() (badversion.Version, error) { } func TestFlightVersion(version badversion.Version) string { - return F.ToString(version.Major, ".", version.Minor, ".10") + return F.ToString(version.Major, ".", version.Minor, ".1000") } diff --git a/cmd/sing-box/cmd_check.go b/cmd/sing-box/cmd_check.go index 29a39081..5bd1093f 100644 --- a/cmd/sing-box/cmd_check.go +++ b/cmd/sing-box/cmd_check.go @@ -5,6 +5,7 @@ import ( "github.com/sagernet/sing-box" "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/service" "github.com/spf13/cobra" ) @@ -30,7 +31,7 @@ func check() error { if err != nil { return err } - ctx, cancel := context.WithCancel(globalCtx) + ctx, cancel := context.WithCancel(service.ExtendContext(globalCtx)) instance, err := box.New(box.Options{ Context: ctx, Options: options, diff --git a/cmd/sing-box/cmd_run.go b/cmd/sing-box/cmd_run.go index 6094aa94..0ecc9c68 100644 --- a/cmd/sing-box/cmd_run.go +++ b/cmd/sing-box/cmd_run.go @@ -19,6 +19,7 @@ import ( E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/service" "github.com/spf13/cobra" ) @@ -136,7 +137,7 @@ func create(options option.Options) (*box.Box, context.CancelFunc, error) { } options.Log.DisableColor = true } - ctx, cancel := context.WithCancel(globalCtx) + ctx, cancel := context.WithCancel(service.ExtendContext(globalCtx)) instance, err := box.New(box.Options{ Context: ctx, Options: options, diff --git a/cmd/sing-box/cmd_tools.go b/cmd/sing-box/cmd_tools.go index 55e5b458..3c60a49a 100644 --- a/cmd/sing-box/cmd_tools.go +++ b/cmd/sing-box/cmd_tools.go @@ -7,6 +7,7 @@ import ( "github.com/sagernet/sing-box" E "github.com/sagernet/sing/common/exceptions" N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" "github.com/spf13/cobra" ) @@ -30,7 +31,7 @@ func createPreStartedClient() (*box.Box, error) { return nil, err } } - instance, err := box.New(box.Options{Context: globalCtx, Options: options}) + instance, err := box.New(box.Options{Context: service.ExtendContext(globalCtx), Options: options}) if err != nil { return nil, E.Cause(err, "create service") } diff --git a/common/process/searcher_linux.go b/common/process/searcher_linux.go index d226c0e4..570e23d3 100644 --- a/common/process/searcher_linux.go +++ b/common/process/searcher_linux.go @@ -22,7 +22,11 @@ import ( "github.com/sagernet/sing/contrab/maphash" ) -const pathProc = "/proc" +const ( + pathProc = "/proc" + + processPathsAllUsers = ^uint32(0) +) var _ Searcher = (*linuxSearcher)(nil) @@ -101,6 +105,8 @@ func (s *linuxSearcher) FindProcessInfo(ctx context.Context, network string, sou } func (s *linuxSearcher) resolveSocketByNetlink(network string, source netip.AddrPort, destination netip.AddrPort) (inode, uid uint32, err error) { + source = netip.AddrPortFrom(source.Addr().Unmap(), source.Port()) + destination = netip.AddrPortFrom(destination.Addr().Unmap(), destination.Port()) family, protocol, err := socketDiagSettings(network, source) if err != nil { return 0, 0, err @@ -118,28 +124,33 @@ func (s *linuxSearcher) resolveSocketByNetlink(network string, source netip.Addr return 0, 0, err } } - return querySocketDiagOnce(family, protocol, source) + return dumpSocketDiag(family, protocol, source, destination) } +// The socket keeps the uid it was created with, while /proc reflects the +// current uid of the process, so a socket created before a privilege drop +// only appears under a scan of all users. func (s *linuxSearcher) findProcessPath(targetInode, uid uint32) (string, error) { - if cached, ok := s.processPathCache.Get(uid); ok { - if processPath, found := cached.entries[targetInode]; found { + for _, scanUID := range []uint32{uid, processPathsAllUsers} { + if cached, ok := s.processPathCache.Get(scanUID); ok { + if processPath, found := cached.entries[targetInode]; found { + return processPath, nil + } + } + processPaths, err := buildProcessPaths(scanUID) + if err != nil { + return "", err + } + s.processPathCache.Add(scanUID, &uidProcessPaths{entries: processPaths}) + processPath, found := processPaths[targetInode] + if found { return processPath, nil } } - processPaths, err := buildProcessPathsByUID(uid) - if err != nil { - return "", err - } - s.processPathCache.Add(uid, &uidProcessPaths{entries: processPaths}) - processPath, found := processPaths[targetInode] - if !found { - return "", E.New("process of uid(", uid, "), inode(", targetInode, ") not found") - } - return processPath, nil + return "", E.New("process of uid(", uid, "), inode(", targetInode, ") not found") } -func buildProcessPathsByUID(uid uint32) (map[uint32]string, error) { +func buildProcessPaths(uid uint32) (map[uint32]string, error) { files, err := os.ReadDir(pathProc) if err != nil { return nil, err @@ -157,7 +168,7 @@ func buildProcessPathsByUID(uid uint32) (map[uint32]string, error) { } return nil, err } - if info.Sys().(*syscall.Stat_t).Uid != uid { + if uid != processPathsAllUsers && info.Sys().(*syscall.Stat_t).Uid != uid { continue } processPath := filepath.Join(pathProc, file.Name()) diff --git a/common/process/socket_diag_linux.go b/common/process/socket_diag_linux.go index 0adb3d13..b4769b85 100644 --- a/common/process/socket_diag_linux.go +++ b/common/process/socket_diag_linux.go @@ -9,6 +9,7 @@ import ( "os" "sync" "syscall" + "time" E "github.com/sagernet/sing/common/exceptions" N "github.com/sagernet/sing/common/network" @@ -19,6 +20,7 @@ const ( sizeOfSocketDiagRequest = syscall.SizeofNlMsghdr + sizeOfSocketDiagRequestData socketDiagResponseMinSize = 72 socketDiagByFamily = 20 + socketDiagTimeout = 100 * time.Millisecond ) type socketDiagConn struct { @@ -28,6 +30,13 @@ type socketDiagConn struct { fd int } +type socketDiagEntry struct { + source netip.AddrPort + destination netip.AddrPort + uid uint32 + inode uint32 +} + func socketDiagConnIndex(family, protocol uint8) int { index := 0 if protocol == syscall.IPPROTO_UDP { @@ -83,15 +92,6 @@ func (c *socketDiagConn) query(source netip.AddrPort, destination netip.AddrPort return 0, 0, err } -func querySocketDiagOnce(family, protocol uint8, source netip.AddrPort) (inode, uid uint32, err error) { - fd, err := openSocketDiag() - if err != nil { - return 0, 0, E.Cause(err, "dial netlink") - } - defer syscall.Close(fd) - return querySocketDiag(fd, packSocketDiagRequest(family, protocol, source, netip.AddrPort{}, true)) -} - func (c *socketDiagConn) ensureOpenLocked() error { if c.fd != -1 { return nil @@ -109,12 +109,12 @@ func openSocketDiag() (int, error) { if err != nil { return -1, err } - timeout := &syscall.Timeval{Usec: 100} - if err = syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, syscall.SO_SNDTIMEO, timeout); err != nil { + timeout := syscall.NsecToTimeval(socketDiagTimeout.Nanoseconds()) + if err = syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, syscall.SO_SNDTIMEO, &timeout); err != nil { syscall.Close(fd) return -1, err } - if err = syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, syscall.SO_RCVTIMEO, timeout); err != nil { + if err = syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, syscall.SO_RCVTIMEO, &timeout); err != nil { syscall.Close(fd) return -1, err } @@ -196,36 +196,132 @@ func querySocketDiag(fd int, request []byte) (inode, uid uint32, err error) { if err != nil { return 0, 0, E.Cause(err, "parse netlink message") } - return unpackSocketDiagMessages(messages) -} - -func unpackSocketDiagMessages(messages []syscall.NetlinkMessage) (inode, uid uint32, err error) { for _, message := range messages { switch message.Header.Type { - case syscall.NLMSG_DONE: - continue case syscall.NLMSG_ERROR: err = unpackSocketDiagError(&message) if err != nil { return 0, 0, err } case socketDiagByFamily: - inode, uid = unpackSocketDiagResponse(&message) - if inode != 0 || uid != 0 { - return inode, uid, nil + entry, valid := unpackSocketDiagEntry(&message) + if valid && (entry.inode != 0 || entry.uid != 0) { + return entry.inode, entry.uid, nil } } } return 0, 0, ErrNotFound } -func unpackSocketDiagResponse(msg *syscall.NetlinkMessage) (inode, uid uint32) { - if len(msg.Data) < socketDiagResponseMinSize { - return 0, 0 +// The dump only filters by port (inet_diag_dump_icsk, udp_dump), so every socket +// in the namespace with the requested local port is returned regardless of address. +// A dual-stack socket carrying a v4-mapped address is only listed under AF_INET6. +func dumpSocketDiag(family, protocol uint8, source netip.AddrPort, destination netip.AddrPort) (inode, uid uint32, err error) { + families := []uint8{family} + if family == syscall.AF_INET { + families = append(families, syscall.AF_INET6) } - uid = binary.NativeEndian.Uint32(msg.Data[64:68]) - inode = binary.NativeEndian.Uint32(msg.Data[68:72]) - return inode, uid + for _, dumpFamily := range families { + inode, uid, err = dumpSocketDiagFamily(dumpFamily, protocol, source, destination) + if err == nil || !errors.Is(err, ErrNotFound) { + return inode, uid, err + } + } + return 0, 0, ErrNotFound +} + +func dumpSocketDiagFamily(family, protocol uint8, source netip.AddrPort, destination netip.AddrPort) (inode, uid uint32, err error) { + fd, err := openSocketDiag() + if err != nil { + return 0, 0, E.Cause(err, "dial netlink") + } + defer syscall.Close(fd) + _, err = syscall.Write(fd, packSocketDiagRequest(family, protocol, source, netip.AddrPort{}, true)) + if err != nil { + return 0, 0, E.Cause(err, "write netlink request") + } + var ( + localMatch socketDiagEntry + hasLocalMatch bool + wildcardMatch socketDiagEntry + hasWildcardMatch bool + buffer = make([]byte, 64<<10) + n int + messages []syscall.NetlinkMessage + ) + for { + n, err = syscall.Read(fd, buffer) + if err != nil { + return 0, 0, E.Cause(err, "read netlink response") + } + messages, err = syscall.ParseNetlinkMessage(buffer[:n]) + if err != nil { + return 0, 0, E.Cause(err, "parse netlink message") + } + if len(messages) == 0 { + return 0, 0, E.New("empty netlink response") + } + for _, message := range messages { + switch message.Header.Type { + case syscall.NLMSG_DONE: + if hasLocalMatch { + return localMatch.inode, localMatch.uid, nil + } + if hasWildcardMatch { + return wildcardMatch.inode, wildcardMatch.uid, nil + } + return 0, 0, ErrNotFound + case syscall.NLMSG_ERROR: + err = unpackSocketDiagError(&message) + if err != nil { + return 0, 0, err + } + case socketDiagByFamily: + entry, valid := unpackSocketDiagEntry(&message) + if !valid || (entry.inode == 0 && entry.uid == 0) || entry.source.Port() != source.Port() { + continue + } + if entry.source.Addr() == source.Addr() && (!destination.IsValid() || entry.destination == destination) { + return entry.inode, entry.uid, nil + } + if protocol != syscall.IPPROTO_UDP { + continue + } + if !hasLocalMatch && entry.source.Addr() == source.Addr() { + hasLocalMatch = true + localMatch = entry + } + if !hasWildcardMatch && entry.source.Addr().IsUnspecified() { + hasWildcardMatch = true + wildcardMatch = entry + } + } + } + } +} + +func unpackSocketDiagEntry(msg *syscall.NetlinkMessage) (socketDiagEntry, bool) { + if len(msg.Data) < socketDiagResponseMinSize { + return socketDiagEntry{}, false + } + data := msg.Data + var sourceAddr, destinationAddr netip.Addr + switch data[0] { + case syscall.AF_INET: + sourceAddr = netip.AddrFrom4([4]byte(data[8:12])) + destinationAddr = netip.AddrFrom4([4]byte(data[24:28])) + case syscall.AF_INET6: + sourceAddr = netip.AddrFrom16([16]byte(data[8:24])).Unmap() + destinationAddr = netip.AddrFrom16([16]byte(data[24:40])).Unmap() + default: + return socketDiagEntry{}, false + } + return socketDiagEntry{ + source: netip.AddrPortFrom(sourceAddr, binary.BigEndian.Uint16(data[4:6])), + destination: netip.AddrPortFrom(destinationAddr, binary.BigEndian.Uint16(data[6:8])), + uid: binary.NativeEndian.Uint32(data[64:68]), + inode: binary.NativeEndian.Uint32(data[68:72]), + }, true } func unpackSocketDiagError(msg *syscall.NetlinkMessage) error { diff --git a/common/process/socket_diag_linux_test.go b/common/process/socket_diag_linux_test.go new file mode 100644 index 00000000..1aec6f20 --- /dev/null +++ b/common/process/socket_diag_linux_test.go @@ -0,0 +1,108 @@ +//go:build linux + +package process + +import ( + "context" + "net" + "net/netip" + "os" + "syscall" + "testing" + + "github.com/sagernet/sing-box/log" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "github.com/stretchr/testify/require" +) + +func socketInode(t *testing.T, conn syscall.Conn) uint32 { + rawConn, err := conn.SyscallConn() + require.NoError(t, err) + var inode uint32 + err = rawConn.Control(func(fd uintptr) { + var stat syscall.Stat_t + require.NoError(t, syscall.Fstat(int(fd), &stat)) + inode = uint32(stat.Ino) + }) + require.NoError(t, err) + return inode +} + +func TestDumpSocketDiagTCP(t *testing.T) { + t.Parallel() + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + first, err := (&net.Dialer{LocalAddr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 2)}}).Dial("tcp", listener.Addr().String()) + require.NoError(t, err) + defer first.Close() + second, err := (&net.Dialer{LocalAddr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 3), Port: int(M.AddrPortFromNet(first.LocalAddr()).Port())}}).Dial("tcp", listener.Addr().String()) + require.NoError(t, err) + defer second.Close() + destination := M.AddrPortFromNet(listener.Addr()) + for _, conn := range []net.Conn{second, first} { + inode, uid, err := dumpSocketDiag(syscall.AF_INET, syscall.IPPROTO_TCP, M.AddrPortFromNet(conn.LocalAddr()), destination) + require.NoError(t, err) + require.Equal(t, socketInode(t, conn.(syscall.Conn)), inode) + require.Equal(t, uint32(os.Getuid()), uid) + inode, _, err = dumpSocketDiag(syscall.AF_INET, syscall.IPPROTO_TCP, M.AddrPortFromNet(conn.LocalAddr()), netip.AddrPort{}) + require.NoError(t, err) + require.Equal(t, socketInode(t, conn.(syscall.Conn)), inode) + } + _, _, err = dumpSocketDiag(syscall.AF_INET, syscall.IPPROTO_TCP, netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 4}), M.AddrPortFromNet(first.LocalAddr()).Port()), destination) + require.ErrorIs(t, err, ErrNotFound) +} + +func TestDumpSocketDiagUDP(t *testing.T) { + t.Parallel() + first, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 2)}) + require.NoError(t, err) + defer first.Close() + port := M.AddrPortFromNet(first.LocalAddr()).Port() + second, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 3), Port: int(port)}) + require.NoError(t, err) + defer second.Close() + for _, conn := range []*net.UDPConn{second, first} { + inode, _, err := dumpSocketDiag(syscall.AF_INET, syscall.IPPROTO_UDP, M.AddrPortFromNet(conn.LocalAddr()), netip.AddrPort{}) + require.NoError(t, err) + require.Equal(t, socketInode(t, conn), inode) + } + _, _, err = dumpSocketDiag(syscall.AF_INET, syscall.IPPROTO_UDP, netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 4}), port), netip.AddrPort{}) + require.ErrorIs(t, err, ErrNotFound) + + wildcard, err := net.ListenUDP("udp4", &net.UDPAddr{}) + require.NoError(t, err) + defer wildcard.Close() + inode, _, err := dumpSocketDiag(syscall.AF_INET, syscall.IPPROTO_UDP, netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 4}), M.AddrPortFromNet(wildcard.LocalAddr()).Port()), netip.AddrPort{}) + require.NoError(t, err) + require.Equal(t, socketInode(t, wildcard), inode) +} + +func TestLinuxSearcherFindProcessInfo(t *testing.T) { + t.Parallel() + searcher, err := NewSearcher(Config{Logger: log.NewNOPFactory().NewLogger("test")}) + require.NoError(t, err) + defer searcher.Close() + executable, err := os.Executable() + require.NoError(t, err) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + tcpConn, err := net.Dial("tcp", listener.Addr().String()) + require.NoError(t, err) + defer tcpConn.Close() + info, err := searcher.FindProcessInfo(context.Background(), N.NetworkTCP, M.AddrPortFromNet(tcpConn.LocalAddr()), M.AddrPortFromNet(listener.Addr())) + require.NoError(t, err) + require.Equal(t, executable, info.ProcessPath) + require.Equal(t, int32(os.Getuid()), info.UserId) + + udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{}) + require.NoError(t, err) + defer udpConn.Close() + info, err = searcher.FindProcessInfo(context.Background(), N.NetworkUDP, netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), M.AddrPortFromNet(udpConn.LocalAddr()).Port()), netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), 53)) + require.NoError(t, err) + require.Equal(t, executable, info.ProcessPath) +} diff --git a/daemon/attached_service.go b/daemon/attached_service.go index b726ac2c..43c6b10a 100644 --- a/daemon/attached_service.go +++ b/daemon/attached_service.go @@ -3,8 +3,6 @@ package daemon import ( "context" "time" - - "github.com/sagernet/sing-box/log" ) const defaultAttachedLogMaxLines = 3000 @@ -20,9 +18,9 @@ func NewAttachedService(ctx context.Context) *StartedService { s.serviceStatus = &ServiceStatus{Status: ServiceStatus_STARTED} s.startedAt = time.Now() instance.urlTestHistoryStorage.AddUpdateHook(s.urlTestSubscriber) - if instance.clashServer != nil { - instance.clashServer.AddModeUpdateHook(s.clashModeSubscriber) + if instance.clashMode != nil { + instance.clashMode.AddUpdateHook(s.clashModeSubscriber) } - instance.logFactory.(log.ObservableFactory).AttachPlatformWriter(s) + instance.logFactory.AttachPlatformWriter(s) return s } diff --git a/daemon/instance.go b/daemon/instance.go index 82959ff9..a1ca7a28 100644 --- a/daemon/instance.go +++ b/daemon/instance.go @@ -9,6 +9,7 @@ import ( "github.com/sagernet/sing-box/common/trafficcontrol" "github.com/sagernet/sing-box/common/urltest" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/experimental/clashmode" "github.com/sagernet/sing-box/experimental/deprecated" "github.com/sagernet/sing-box/experimental/locale" "github.com/sagernet/sing-box/log" @@ -25,7 +26,7 @@ type Instance struct { cancel context.CancelFunc instance *box.Box connectionManager adapter.ConnectionManager - clashServer adapter.ClashServer + clashMode *clashmode.Manager trafficManager *trafficcontrol.Manager cacheFile adapter.CacheFile pauseManager pause.Manager @@ -38,6 +39,7 @@ type Instance struct { func (s *StartedService) CheckConfig(ctx context.Context, configContent string) error { selectedLocale := locale.FromContext(ctx) ctx, _ = locale.ContextWithLocale(s.ctx, selectedLocale.Locale) + ctx = service.ExtendContext(ctx) options, err := parseConfig(ctx, configContent) if err != nil { return err @@ -134,7 +136,7 @@ func (s *StartedService) newInstance(ctx context.Context, profileContent string, } i.instance = boxInstance i.connectionManager = service.FromContext[adapter.ConnectionManager](ctx) - i.clashServer = service.FromContext[adapter.ClashServer](ctx) + i.clashMode = service.PtrFromContext[clashmode.Manager](ctx) i.trafficManager = service.PtrFromContext[trafficcontrol.Manager](ctx) i.pauseManager = service.FromContext[pause.Manager](ctx) i.cacheFile = service.FromContext[adapter.CacheFile](ctx) @@ -149,7 +151,7 @@ func attachInstance(ctx context.Context) *Instance { return &Instance{ ctx: ctx, connectionManager: service.FromContext[adapter.ConnectionManager](ctx), - clashServer: service.FromContext[adapter.ClashServer](ctx), + clashMode: service.PtrFromContext[clashmode.Manager](ctx), trafficManager: service.PtrFromContext[trafficcontrol.Manager](ctx), pauseManager: service.FromContext[pause.Manager](ctx), cacheFile: service.FromContext[adapter.CacheFile](ctx), diff --git a/daemon/log_ring.go b/daemon/log_ring.go new file mode 100644 index 00000000..f59e9cc2 --- /dev/null +++ b/daemon/log_ring.go @@ -0,0 +1,39 @@ +package daemon + +import ( + "github.com/sagernet/sing-box/log" +) + +type logRing struct { + entries []*log.Entry + maxLines int + start int +} + +func (r *logRing) push(entry *log.Entry) { + if r.maxLines <= 0 { + return + } + if len(r.entries) < r.maxLines { + r.entries = append(r.entries, entry) + return + } + r.entries[r.start] = entry + r.start++ + if r.start == len(r.entries) { + r.start = 0 + } +} + +func (r *logRing) array() []*log.Entry { + result := make([]*log.Entry, 0, len(r.entries)) + result = append(result, r.entries[r.start:]...) + result = append(result, r.entries[:r.start]...) + return result +} + +func (r *logRing) reset() { + clear(r.entries) + r.entries = r.entries[:0] + r.start = 0 +} diff --git a/daemon/managed_service.go b/daemon/managed_service.go index 14c2bd57..dfc986cb 100644 --- a/daemon/managed_service.go +++ b/daemon/managed_service.go @@ -6,7 +6,6 @@ import ( "unsafe" "github.com/sagernet/sing-box/service/oomkiller" - "github.com/sagernet/sing/common/memory" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -18,20 +17,20 @@ var _ ManagedServiceServer = (*ManagedService)(nil) type ManagedService struct { handler ManagedHandler debug bool - oomReporter oomkiller.OOMReporter + oomRecorder *oomkiller.Recorder } type ManagedServiceOptions struct { Handler ManagedHandler Debug bool - OOMReporter oomkiller.OOMReporter + OOMRecorder *oomkiller.Recorder } func NewManagedService(options ManagedServiceOptions) *ManagedService { return &ManagedService{ handler: options.Handler, debug: options.Debug, - oomReporter: options.OOMReporter, + oomRecorder: options.OOMRecorder, } } @@ -87,10 +86,10 @@ func (s *ManagedService) TriggerDebugCrash(ctx context.Context, request *DebugCr } func (s *ManagedService) TriggerOOMReport(ctx context.Context, _ *emptypb.Empty) (*emptypb.Empty, error) { - if s.oomReporter == nil { - return nil, status.Error(codes.Unavailable, "OOM reporter not available") + if s.oomRecorder == nil { + return nil, status.Error(codes.Unavailable, "OOM recorder not available") } - return &emptypb.Empty{}, s.oomReporter.WriteReport(memory.Total()) + return &emptypb.Empty{}, s.oomRecorder.WriteReport() } func (s *ManagedService) mustEmbedUnimplementedManagedServiceServer() { diff --git a/daemon/started_service.go b/daemon/started_service.go index 0ca8e4bb..5e8c7998 100644 --- a/daemon/started_service.go +++ b/daemon/started_service.go @@ -23,7 +23,6 @@ import ( "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/memory" "github.com/sagernet/sing/common/observable" - "github.com/sagernet/sing/common/x/list" "github.com/sagernet/sing/service" "github.com/gofrs/uuid/v5" @@ -47,7 +46,6 @@ type StartedService struct { // platform adapter.PlatformInterface handler PlatformHandler debug bool - logMaxLines int oomKillerEnabled bool oomKillerDisabled bool oomMemoryLimit uint64 @@ -64,7 +62,7 @@ type StartedService struct { serviceStatusSubscriber *observable.Subscriber[*ServiceStatus] serviceStatusObserver *observable.Observer[*ServiceStatus] logAccess sync.RWMutex - logLines list.List[*log.Entry] + logLines logRing logSubscriber *observable.Subscriber[*log.Entry] logObserver *observable.Observer[*log.Entry] instance *Instance @@ -99,7 +97,7 @@ func NewStartedService(options ServiceOptions) *StartedService { // platform: options.Platform, handler: options.Handler, debug: options.Debug, - logMaxLines: options.LogMaxLines, + logLines: logRing{maxLines: options.LogMaxLines}, oomKillerEnabled: options.OOMKillerEnabled, oomKillerDisabled: options.OOMKillerDisabled, oomMemoryLimit: options.OOMMemoryLimit, @@ -140,7 +138,7 @@ func (s *StartedService) GetVersion(ctx context.Context, empty *emptypb.Empty) ( func (s *StartedService) resetLogs() { s.logAccess.Lock() - s.logLines = list.List[*log.Entry]{} + s.logLines.reset() s.logAccess.Unlock() s.logSubscriber.Emit(nil) } @@ -277,8 +275,8 @@ func (s *StartedService) StartOrReloadService(ctx context.Context, profileConten return err } instance.urlTestHistoryStorage.AddUpdateHook(s.urlTestSubscriber) - if instance.clashServer != nil { - instance.clashServer.AddModeUpdateHook(s.clashModeSubscriber) + if instance.clashMode != nil { + instance.clashMode.AddUpdateHook(s.clashModeSubscriber) } s.serviceAccess.Lock() s.instance = instance @@ -378,12 +376,8 @@ func (s *StartedService) SubscribeServiceStatus(empty *emptypb.Empty, server grp } func (s *StartedService) SubscribeLog(empty *emptypb.Empty, server grpc.ServerStreamingServer[Log]) error { - var savedLines []*log.Entry s.logAccess.Lock() - savedLines = make([]*log.Entry, 0, s.logLines.Len()) - for element := s.logLines.Front(); element != nil; element = element.Next() { - savedLines = append(savedLines, element.Value) - } + savedLines := s.logLines.array() subscription, done, err := s.logObserver.Subscribe() s.logAccess.Unlock() if err != nil { @@ -635,14 +629,14 @@ func (s *StartedService) GetClashModeStatus(ctx context.Context, empty *emptypb. s.serviceAccess.RUnlock() return nil, os.ErrInvalid } - clashServer := s.instance.clashServer + clashMode := s.instance.clashMode s.serviceAccess.RUnlock() - if clashServer == nil { + if clashMode == nil { return nil, status.Error(codes.NotFound, "clash mode not available") } return &ClashModeStatus{ - ModeList: clashServer.ModeList(), - CurrentMode: clashServer.Mode(), + ModeList: clashMode.ModeList(), + CurrentMode: clashMode.Mode(), }, nil } @@ -665,12 +659,12 @@ func (s *StartedService) SubscribeClashMode(empty *emptypb.Empty, server grpc.Se s.serviceAccess.RLock() var message *ClashMode if s.serviceStatus.Status == ServiceStatus_STARTED { - clashServer := s.instance.clashServer - if clashServer == nil { + clashMode := s.instance.clashMode + if clashMode == nil { s.serviceAccess.RUnlock() return status.Error(codes.NotFound, "clash mode not available") } - message = &ClashMode{Mode: clashServer.Mode()} + message = &ClashMode{Mode: clashMode.Mode()} } else { message = &ClashMode{} } @@ -700,12 +694,12 @@ func (s *StartedService) SetClashMode(ctx context.Context, request *ClashMode) ( s.serviceAccess.RUnlock() return nil, os.ErrInvalid } - clashServer := s.instance.clashServer + clashMode := s.instance.clashMode s.serviceAccess.RUnlock() - if clashServer == nil { + if clashMode == nil { return nil, status.Error(codes.NotFound, "clash mode not available") } - clashServer.SetMode(request.Mode) + clashMode.SetMode(request.Mode) return &emptypb.Empty{}, nil } @@ -2092,10 +2086,7 @@ func (s *StartedService) mustEmbedUnimplementedStartedServiceServer() { func (s *StartedService) WriteMessage(level log.Level, message string) { item := &log.Entry{Level: level, Message: message} s.logAccess.Lock() - s.logLines.PushBack(item) - if s.logLines.Len() > s.logMaxLines { - s.logLines.Remove(s.logLines.Front()) - } + s.logLines.push(item) s.logAccess.Unlock() s.logSubscriber.Emit(item) if s.debug { @@ -2106,7 +2097,7 @@ func (s *StartedService) WriteMessage(level log.Level, message string) { func (s *StartedService) SavedLog() []*log.Entry { s.logAccess.RLock() defer s.logAccess.RUnlock() - return s.logLines.Array() + return s.logLines.array() } func (s *StartedService) Instance() *Instance { diff --git a/dns/router.go b/dns/router.go index 46355c3f..e7c0e964 100644 --- a/dns/router.go +++ b/dns/router.go @@ -1073,6 +1073,19 @@ func (r *Router) prepareExchange(ctx context.Context, message *mDNS.Msg) (*dnsEx Question: message.Question, }, nil } + if isResolverDiscoveryQuery(message.Question[0]) { + r.logger.DebugContext(ctx, "rejected resolver discovery query ", FormatQuestion(message.Question[0].String())) + return nil, &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Id: message.Id, + Response: true, + RecursionDesired: message.RecursionDesired, + RecursionAvailable: true, + Rcode: mDNS.RcodeSuccess, + }, + Question: message.Question, + }, nil + } r.rulesAccess.RLock() if r.closing { r.rulesAccess.RUnlock() @@ -1335,6 +1348,10 @@ response: return responseAddrs, err } +func isResolverDiscoveryQuery(question mDNS.Question) bool { + return question.Qtype == mDNS.TypeSVCB && len(question.Name) > 5 && strings.EqualFold(question.Name[:5], "_dns.") +} + func isAddressQuery(message *mDNS.Msg) bool { for _, question := range message.Question { if question.Qtype == mDNS.TypeA || question.Qtype == mDNS.TypeAAAA || question.Qtype == mDNS.TypeHTTPS { diff --git a/dns/transport/dhcp/dhcp.go b/dns/transport/dhcp/dhcp.go index b247f544..239a4663 100644 --- a/dns/transport/dhcp/dhcp.go +++ b/dns/transport/dhcp/dhcp.go @@ -109,7 +109,11 @@ func (t *Transport) Start(stage adapter.StartStage) error { return nil } if t.interfaceName == "" { - t.interfaceCallback = t.networkManager.InterfaceMonitor().RegisterCallback(t.interfaceUpdated) + 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) } go func() { err := t.fetch() diff --git a/docs/changelog.md b/docs/changelog.md index d5c335be..35bfd648 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,10 @@ icon: material/alert-decagram --- +#### 1.14.1 + +* Fixes and improvements + #### 1.14.0 Important changes since 1.13: @@ -54,7 +58,7 @@ Important changes since 1.13: * Update gVisor to 20260727.0 * Update Tailscale to v1.102.1 * Update uTLS to v1.8.7 -* Update NaiveProxy to v150.0.7871.63-1 +* Update NaiveProxy to v150.0.7871.63-2 **1**: diff --git a/docs/clients/android/features.md b/docs/clients/android/features.md index b76a6418..da1fa6f3 100644 --- a/docs/clients/android/features.md +++ b/docs/clients/android/features.md @@ -25,7 +25,6 @@ SFA provides an unprivileged TUN implementation through Android VpnService. | `inet6_route_address` | :material-check: | / | | `inet4_route_exclude_address` | :material-check: | / | | `inet6_route_exclude_address` | :material-check: | / | -| `endpoint_independent_nat` | :material-check: | / | | `stack` | :material-check: | / | | `include_interface` | :material-close: | No permission | | `exclude_interface` | :material-close: | No permission | diff --git a/docs/clients/apple/features.md b/docs/clients/apple/features.md index 67f0a84b..027cb603 100644 --- a/docs/clients/apple/features.md +++ b/docs/clients/apple/features.md @@ -27,7 +27,6 @@ SFI/SFM/SFT provides an unprivileged TUN implementation through NetworkExtension | `inet6_route_address` | :material-check: | / | | `inet4_route_exclude_address` | :material-check: | / | | `inet6_route_exclude_address` | :material-check: | / | -| `endpoint_independent_nat` | :material-check: | / | | `stack` | :material-check: | / | | `include_interface` | :material-close:️ | Not implemented | | `exclude_interface` | :material-close:️ | Not implemented | diff --git a/docs/configuration/inbound/tun.md b/docs/configuration/inbound/tun.md index eef22028..ec8829ea 100644 --- a/docs/configuration/inbound/tun.md +++ b/docs/configuration/inbound/tun.md @@ -550,13 +550,10 @@ Exclude custom routes when `auto_route` is enabled. #### endpoint_independent_nat -!!! info "" +This option has had no effect since sing-box 1.11.0 and can be removed from the configuration. - This item is only available on the gvisor stack, other stacks are endpoint-independent NAT by default. - -Enable endpoint-independent NAT. - -Performance may degrade slightly, so it is not recommended to enable on when it is not needed. +Since sing-box 1.14.0, use [UDP NAT fields](/configuration/shared/udp-nat/) +to customize the mapping and filtering behavior. #### stack diff --git a/docs/configuration/inbound/tun.zh.md b/docs/configuration/inbound/tun.zh.md index 0084af78..98e2e49e 100644 --- a/docs/configuration/inbound/tun.zh.md +++ b/docs/configuration/inbound/tun.zh.md @@ -534,9 +534,9 @@ sing-box DNS 模块,等价于一条 #### endpoint_independent_nat -启用独立于端点的 NAT。 +此选项自 sing-box 1.11.0 起不再生效,可从配置中移除。 -性能可能会略有下降,所以不建议在不需要的时候开启。 +自 sing-box 1.14.0 起,可使用 [UDP NAT 字段](/zh/configuration/shared/udp-nat/)自定义映射和过滤行为。 #### stack diff --git a/docs/migration.md b/docs/migration.md index 3b006288..8a78869f 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -9,11 +9,22 @@ icon: material/arrange-bring-forward Apple platform clients migrated to a new Apple developer account, so the macOS standalone client is a new application, and profiles and settings are not inherited. -Before starting sing-box 1.14.0-rc.2 or later, they can be migrated using the following command: +Before starting sing-box 1.14.0-rc.2 or later, they can be migrated using the following commands: ```bash mv ~/Library/Group\ Containers/287TTNZF8L.io.nekohasekai.sfavt \ ~/Library/Group\ Containers/P8XK3KHB48.io.nekohasekai.sfamt +xattr -c ~/Library/Group\ Containers/P8XK3KHB48.io.nekohasekai.sfamt +rm ~/Library/Group\ Containers/P8XK3KHB48.io.nekohasekai.sfamt/.com.apple.containermanagerd.metadata.plist +``` + +If you have already migrated using an earlier version of this command and a permission prompt +appears at startup, run the following commands and restart the application: + +```bash +xattr -c ~/Library/Group\ Containers/P8XK3KHB48.io.nekohasekai.sfamt +rm ~/Library/Group\ Containers/P8XK3KHB48.io.nekohasekai.sfamt/.com.apple.containermanagerd.metadata.plist +tccutil reset All io.nekohasekai.sfamt.standalone ``` ### Migrate inline ACME to certificate provider diff --git a/docs/migration.zh.md b/docs/migration.zh.md index 4d003e1e..a710b17c 100644 --- a/docs/migration.zh.md +++ b/docs/migration.zh.md @@ -4,6 +4,28 @@ icon: material/arrange-bring-forward ## 1.14.0 +### 迁移 macOS standalone 客户端数据 + +Apple 平台客户端已迁移至新的 Apple 开发者账户,因此 macOS standalone 客户端是一个新应用, +配置文件与设置不会被继承。 + +在启动 sing-box 1.14.0-rc.2 或更高版本之前,可使用以下命令迁移: + +```bash +mv ~/Library/Group\ Containers/287TTNZF8L.io.nekohasekai.sfavt \ + ~/Library/Group\ Containers/P8XK3KHB48.io.nekohasekai.sfamt +xattr -c ~/Library/Group\ Containers/P8XK3KHB48.io.nekohasekai.sfamt +rm ~/Library/Group\ Containers/P8XK3KHB48.io.nekohasekai.sfamt/.com.apple.containermanagerd.metadata.plist +``` + +如果您已使用此命令的早期版本迁移,且启动时出现权限弹窗,请执行以下命令并重新启动应用: + +```bash +xattr -c ~/Library/Group\ Containers/P8XK3KHB48.io.nekohasekai.sfamt +rm ~/Library/Group\ Containers/P8XK3KHB48.io.nekohasekai.sfamt/.com.apple.containermanagerd.metadata.plist +tccutil reset All io.nekohasekai.sfamt.standalone +``` + ### 迁移内联 ACME 到证书提供者 TLS 中的内联 ACME 选项已废弃,且可以被证书提供者替代。 diff --git a/experimental/boxdd/authorize_takeover_linux.go b/experimental/boxdd/authorize_linux.go similarity index 62% rename from experimental/boxdd/authorize_takeover_linux.go rename to experimental/boxdd/authorize_linux.go index 8fd1912c..9cdf0430 100644 --- a/experimental/boxdd/authorize_takeover_linux.go +++ b/experimental/boxdd/authorize_linux.go @@ -7,6 +7,7 @@ import ( "crypto/rand" "encoding/hex" "math" + "os" "strconv" E "github.com/sagernet/sing/common/exceptions" @@ -17,11 +18,12 @@ import ( ) const ( - policyKitService = "org.freedesktop.PolicyKit1" - policyKitAuthorityPath = dbus.ObjectPath("/org/freedesktop/PolicyKit1/Authority") - policyKitAuthorityInterface = "org.freedesktop.PolicyKit1.Authority" - policyKitTakeOverAction = "io.nekohasekai.sfl.take-over-service" - policyKitAllowUserInteraction = uint32(1) + policyKitService = "org.freedesktop.PolicyKit1" + policyKitAuthorityPath = dbus.ObjectPath("/org/freedesktop/PolicyKit1/Authority") + policyKitAuthorityInterface = "org.freedesktop.PolicyKit1.Authority" + policyKitTakeOverAction = "io.nekohasekai.sfl.take-over-service" + policyKitSetInsecureModeAction = "io.nekohasekai.sfl.set-insecure-mode" + policyKitAllowUserInteraction = uint32(1) ) type policyKitSubject struct { @@ -36,6 +38,27 @@ type policyKitAuthorizationResult struct { } func authorizeTakeOver(ctx context.Context, identity peerIdentity) error { + return checkPolicyKitAuthorization(ctx, identity, policyKitTakeOverAction) +} + +func authorizeSetInsecureMode(ctx context.Context, identity peerIdentity, enabled bool) error { + if enabled { + return checkPolicyKitAuthorization(ctx, identity, policyKitSetInsecureModeAction) + } + ownerUserID, err := loadOwner() + if err != nil { + if os.IsNotExist(err) { + return status.Error(codes.PermissionDenied, "the service has no owner") + } + return err + } + if ownerUserID != identity.UserID { + return status.Error(codes.PermissionDenied, "the service is owned by another user") + } + return nil +} + +func checkPolicyKitAuthorization(ctx context.Context, identity peerIdentity, action string) error { if listenAddress != "" { return nil } @@ -72,7 +95,7 @@ func authorizeTakeOver(ctx context.Context, identity peerIdentity) error { 0, resultChannel, subject, - policyKitTakeOverAction, + action, map[string]string{}, policyKitAllowUserInteraction, cancellationID, @@ -88,7 +111,10 @@ func authorizeTakeOver(ctx context.Context, identity peerIdentity) error { return E.Cause(err, "read PolicyKit authorization result") } if !result.Authorized { - return status.Error(codes.PermissionDenied, "take over authorization was denied") + if result.Challenge { + return status.Error(codes.Unauthenticated, "no authentication agent is available") + } + return status.Error(codes.PermissionDenied, "authorization was denied") } return nil case <-ctx.Done(): @@ -97,6 +123,6 @@ func authorizeTakeOver(ctx context.Context, identity peerIdentity) error { 0, cancellationID, ).Err - return status.Error(codes.Canceled, "take over authorization was canceled") + return status.Error(codes.Canceled, "authorization was canceled") } } diff --git a/experimental/boxdd/authorize_stub.go b/experimental/boxdd/authorize_stub.go new file mode 100644 index 00000000..7c4cf241 --- /dev/null +++ b/experimental/boxdd/authorize_stub.go @@ -0,0 +1,21 @@ +//go:build !linux + +package main + +import ( + "context" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func authorizeTakeOver(ctx context.Context, identity peerIdentity) error { + return nil +} + +func authorizeSetInsecureMode(ctx context.Context, identity peerIdentity, enabled bool) error { + if enabled { + return status.Error(codes.PermissionDenied, "enabling insecure mode requires an elevated service command") + } + return nil +} diff --git a/experimental/boxdd/authorize_takeover_stub.go b/experimental/boxdd/authorize_takeover_stub.go deleted file mode 100644 index c62b68d5..00000000 --- a/experimental/boxdd/authorize_takeover_stub.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !linux - -package main - -import "context" - -func authorizeTakeOver(ctx context.Context, identity peerIdentity) error { - return nil -} diff --git a/experimental/boxdd/desktop_service.go b/experimental/boxdd/desktop_service.go index e9422fec..1942f9fb 100644 --- a/experimental/boxdd/desktop_service.go +++ b/experimental/boxdd/desktop_service.go @@ -247,26 +247,23 @@ func (s *desktopService) SetInsecureModeEnabled(ctx context.Context, request *Se if !insecureModeAvailable() { return nil, status.Error(codes.FailedPrecondition, "insecure mode is not available on this platform") } - if request.Enabled { - return nil, status.Error(codes.PermissionDenied, "enabling insecure mode requires an elevated service command") + err = authorizeSetInsecureMode(ctx, identity, request.Enabled) + if err != nil { + return nil, err } s.daemon.lifecycleAccess.Lock() defer s.daemon.lifecycleAccess.Unlock() if s.daemon.closed { return nil, os.ErrClosed } - err = authorizeDisableInsecureMode(identity) - if err != nil { - return nil, err - } wasEnabled := s.daemon.insecureModeEnabled() err = updateDaemonSettings(workingDirectory, func(settings *daemonSettings) { - settings.InsecureModeEnabled = false + settings.InsecureModeEnabled = request.Enabled }) if err != nil { return nil, err } - if wasEnabled && s.daemon.startedService.Instance() != nil { + if wasEnabled && !request.Enabled && s.daemon.startedService.Instance() != nil { var ownerUserID string ownerUserID, err = loadOwner() if err != nil { diff --git a/experimental/boxdd/insecure_mode_authorize_linux.go b/experimental/boxdd/insecure_mode_authorize_linux.go deleted file mode 100644 index 35769a0c..00000000 --- a/experimental/boxdd/insecure_mode_authorize_linux.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build linux - -package main - -import ( - "os" - - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -func authorizeDisableInsecureMode(identity peerIdentity) error { - ownerUserID, err := loadOwner() - if err != nil { - if os.IsNotExist(err) { - return status.Error(codes.PermissionDenied, "the service has no owner") - } - return err - } - if ownerUserID != identity.UserID { - return status.Error(codes.PermissionDenied, "the service is owned by another user") - } - return nil -} diff --git a/experimental/boxdd/insecure_mode_authorize_stub.go b/experimental/boxdd/insecure_mode_authorize_stub.go deleted file mode 100644 index 8040cbea..00000000 --- a/experimental/boxdd/insecure_mode_authorize_stub.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !linux - -package main - -func authorizeDisableInsecureMode(identity peerIdentity) error { - return nil -} diff --git a/experimental/boxdd/server.go b/experimental/boxdd/server.go index 583a25b5..8e9f6b16 100644 --- a/experimental/boxdd/server.go +++ b/experimental/boxdd/server.go @@ -33,6 +33,7 @@ type Daemon struct { logger log.ContextLogger startedService *daemon.StartedService powerManager *powerreport.Manager + oomRecorder *oomkiller.Recorder server *grpc.Server runtimeWorkingDirectory string lifecycleAccess sync.Mutex @@ -63,14 +64,15 @@ func newDaemon() (*Daemon, error) { Context: ctx, LogMaxLines: 3000, }) - reporter := libbox.NewOOMReporter(d.startedService) - service.MustRegister[oomkiller.OOMReporter](ctx, reporter) + d.oomRecorder = oomkiller.NewRecorder(libbox.OOMRecorderOptions(d.startedService)) + service.MustRegister[*oomkiller.Recorder](ctx, d.oomRecorder) + d.oomRecorder.Start() d.powerManager = powerreport.NewManager() service.MustRegister[*powerreport.Manager](ctx, d.powerManager) managedService := daemon.NewManagedService(daemon.ManagedServiceOptions{ Handler: &managedHandler{d}, Debug: debugEnabled, - OOMReporter: reporter, + OOMRecorder: d.oomRecorder, }) authorizer := newAuthorizer(d) serverOptions := []grpc.ServerOption{ @@ -289,6 +291,7 @@ func (d *Daemon) Close() { } _ = d.startedService.CloseService() d.startedService.Close() + _ = d.oomRecorder.Close() if d.platform != nil { _ = d.platform.Close() } diff --git a/experimental/clashapi.go b/experimental/clashapi.go index 4ad07c8b..2472f040 100644 --- a/experimental/clashapi.go +++ b/experimental/clashapi.go @@ -3,16 +3,13 @@ package experimental import ( "context" "os" - "sort" "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" - "github.com/sagernet/sing/common" ) -type ClashServerConstructor = func(ctx context.Context, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.ClashServer, error) +type ClashServerConstructor = func(ctx context.Context, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.LifecycleService, error) var clashServerConstructor ClashServerConstructor @@ -20,62 +17,9 @@ func RegisterClashServerConstructor(constructor ClashServerConstructor) { clashServerConstructor = constructor } -func NewClashServer(ctx context.Context, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.ClashServer, error) { +func NewClashServer(ctx context.Context, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.LifecycleService, error) { if clashServerConstructor == nil { return nil, os.ErrInvalid } return clashServerConstructor(ctx, logFactory, options) } - -func CalculateClashModeList(options option.Options) []string { - var clashModes []string - clashModes = append(clashModes, extraClashModeFromRule(common.PtrValueOrDefault(options.Route).Rules)...) - clashModes = append(clashModes, extraClashModeFromDNSRule(common.PtrValueOrDefault(options.DNS).Rules)...) - clashModes = common.FilterNotDefault(common.Uniq(clashModes)) - predefinedOrder := []string{ - "Rule", "Global", "Direct", - } - var newClashModes []string - for _, mode := range clashModes { - if !common.Contains(predefinedOrder, mode) { - newClashModes = append(newClashModes, mode) - } - } - sort.Strings(newClashModes) - for _, mode := range predefinedOrder { - if common.Contains(clashModes, mode) { - newClashModes = append(newClashModes, mode) - } - } - return newClashModes -} - -func extraClashModeFromRule(rules []option.Rule) []string { - var clashMode []string - for _, rule := range rules { - switch rule.Type { - case C.RuleTypeDefault: - if rule.DefaultOptions.ClashMode != "" { - clashMode = append(clashMode, rule.DefaultOptions.ClashMode) - } - case C.RuleTypeLogical: - clashMode = append(clashMode, extraClashModeFromRule(rule.LogicalOptions.Rules)...) - } - } - return clashMode -} - -func extraClashModeFromDNSRule(rules []option.DNSRule) []string { - var clashMode []string - for _, rule := range rules { - switch rule.Type { - case C.RuleTypeDefault: - if rule.DefaultOptions.ClashMode != "" { - clashMode = append(clashMode, rule.DefaultOptions.ClashMode) - } - case C.RuleTypeLogical: - clashMode = append(clashMode, extraClashModeFromDNSRule(rule.LogicalOptions.Rules)...) - } - } - return clashMode -} diff --git a/experimental/clashapi/configs.go b/experimental/clashapi/configs.go index 8ae1d258..35a1d4ba 100644 --- a/experimental/clashapi/configs.go +++ b/experimental/clashapi/configs.go @@ -42,8 +42,8 @@ func getConfigs(server *Server, logFactory log.Factory) func(w http.ResponseWrit logLevel = log.LevelError } render.JSON(w, r, &configSchema{ - Mode: server.mode, - ModeList: server.modeList, + Mode: server.clashMode.Mode(), + ModeList: server.clashMode.ModeList(), BindAddress: "*", LogLevel: log.FormatLevel(logLevel), }) @@ -60,7 +60,7 @@ func patchConfigs(server *Server) func(w http.ResponseWriter, r *http.Request) { return } if newConfig.Mode != "" { - server.SetMode(newConfig.Mode) + server.clashMode.SetMode(newConfig.Mode) } render.NoContent(w, r) } diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go index b4284d23..1492d28f 100644 --- a/experimental/clashapi/server.go +++ b/experimental/clashapi/server.go @@ -9,7 +9,6 @@ import ( "os" "runtime" "strings" - "sync" "syscall" "time" @@ -19,12 +18,12 @@ import ( "github.com/sagernet/sing-box/common/urltest" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/experimental" + "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/common/observable" "github.com/sagernet/sing/service" "github.com/sagernet/sing/service/filemanager" "github.com/sagernet/ws" @@ -38,7 +37,7 @@ func init() { experimental.RegisterClashServerConstructor(NewServer) } -var _ adapter.ClashServer = (*Server)(nil) +var _ adapter.LifecycleService = (*Server)(nil) type Server struct { ctx context.Context @@ -52,20 +51,16 @@ type Server struct { httpServer *http.Server trafficManager *trafficcontrol.Manager urlTestHistory *urltest.HistoryStorage + clashMode *clashmode.Manager logDebug bool - mode string - modeList []string - modeUpdateAccess sync.Mutex - modeUpdateHooks []*observable.Subscriber[struct{}] - externalController bool externalUI string externalUIDownloadURL string externalUIDownloadDetour string } -func NewServer(ctx context.Context, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.ClashServer, error) { +func NewServer(ctx context.Context, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.LifecycleService, error) { trafficManager := service.PtrFromContext[trafficcontrol.Manager](ctx) if trafficManager == nil { return nil, E.New("missing traffic manager") @@ -74,6 +69,10 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op if urlTestHistory == nil { return nil, E.New("missing URL test history storage") } + clashMode := service.PtrFromContext[clashmode.Manager](ctx) + if clashMode == nil { + return nil, E.New("missing clash mode manager") + } chiRouter := chi.NewRouter() s := &Server{ ctx: ctx, @@ -90,20 +89,12 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op }, trafficManager: trafficManager, urlTestHistory: urlTestHistory, + clashMode: clashMode, logDebug: logFactory.Level() >= log.LevelDebug, - modeList: options.ModeList, externalController: options.ExternalController != "", externalUIDownloadURL: options.ExternalUIDownloadURL, externalUIDownloadDetour: options.ExternalUIDownloadDetour, } - defaultMode := "Rule" - if options.DefaultMode != "" { - defaultMode = options.DefaultMode - } - if !common.Contains(s.modeList, defaultMode) { - s.modeList = append([]string{defaultMode}, s.modeList...) - } - s.mode = defaultMode //goland:noinspection GoDeprecation //nolint:staticcheck if options.StoreMode || options.StoreSelected || options.StoreFakeIP || options.CacheFile != "" || options.CacheID != "" { @@ -159,45 +150,34 @@ func (s *Server) Name() string { } func (s *Server) Start(stage adapter.StartStage) error { - switch stage { - case adapter.StartStateStart: - cacheFile := service.FromContext[adapter.CacheFile](s.ctx) - if cacheFile != nil { - mode := cacheFile.LoadMode() - if common.Any(s.modeList, func(it string) bool { - return strings.EqualFold(it, mode) - }) { - s.mode = mode - } - } - case adapter.StartStateStarted: - if s.externalController { - s.checkAndDownloadExternalUI() - var ( - listener net.Listener - err error - ) - for range 3 { - listener, err = net.Listen("tcp", s.httpServer.Addr) - if runtime.GOOS == "android" && errors.Is(err, syscall.EADDRINUSE) { - time.Sleep(100 * time.Millisecond) - continue - } - break - } - if err != nil { - return E.Cause(err, "external controller listen error") - } - s.logger.Notice("restful api listening at ", listener.Addr()) - go func() { - err = s.httpServer.Serve(listener) - if err != nil && !errors.Is(err, http.ErrServerClosed) { - s.logger.Error("external controller serve error: ", err) - } - }() - } + if stage != adapter.StartStateStarted { + return nil + } + if s.externalController { + s.checkAndDownloadExternalUI() + var ( + listener net.Listener + err error + ) + for range 3 { + listener, err = net.Listen("tcp", s.httpServer.Addr) + if runtime.GOOS == "android" && errors.Is(err, syscall.EADDRINUSE) { + time.Sleep(100 * time.Millisecond) + continue + } + break + } + if err != nil { + return E.Cause(err, "external controller listen error") + } + s.logger.Notice("restful api listening at ", listener.Addr()) + go func() { + err = s.httpServer.Serve(listener) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + s.logger.Error("external controller serve error: ", err) + } + }() } - return nil } @@ -207,49 +187,6 @@ func (s *Server) Close() error { ) } -func (s *Server) Mode() string { - return s.mode -} - -func (s *Server) ModeList() []string { - return s.modeList -} - -func (s *Server) AddModeUpdateHook(hook *observable.Subscriber[struct{}]) { - s.modeUpdateAccess.Lock() - defer s.modeUpdateAccess.Unlock() - s.modeUpdateHooks = append(s.modeUpdateHooks, hook) -} - -func (s *Server) SetMode(newMode string) { - if !common.Contains(s.modeList, newMode) { - newMode = common.Find(s.modeList, func(it string) bool { - return strings.EqualFold(it, newMode) - }) - } - if !common.Contains(s.modeList, newMode) { - return - } - if newMode == s.mode { - return - } - s.mode = newMode - s.modeUpdateAccess.Lock() - for _, hook := range s.modeUpdateHooks { - hook.Emit(struct{}{}) - } - s.modeUpdateAccess.Unlock() - s.dnsRouter.ClearCache() - cacheFile := service.FromContext[adapter.CacheFile](s.ctx) - if cacheFile != nil { - err := cacheFile.StoreMode(newMode) - if err != nil { - s.logger.Error(E.Cause(err, "save mode")) - } - } - s.logger.Notice("updated mode: ", newMode) -} - 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 new file mode 100644 index 00000000..669651c3 --- /dev/null +++ b/experimental/clashmode/manager.go @@ -0,0 +1,109 @@ +package clashmode + +import ( + "context" + "strings" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/observable" + "github.com/sagernet/sing/service" +) + +type Manager struct { + ctx context.Context + logger log.Logger + dnsRouter adapter.DNSRouter + mode string + modeList []string + updateAccess sync.Mutex + updateHooks []*observable.Subscriber[struct{}] +} + +func NewManager(ctx context.Context, logger log.Logger, defaultMode string, modeList []string) *Manager { + if defaultMode == "" { + defaultMode = "Rule" + } + if !common.Contains(modeList, defaultMode) { + modeList = append([]string{defaultMode}, modeList...) + } + return &Manager{ + ctx: ctx, + logger: logger, + dnsRouter: service.FromContext[adapter.DNSRouter](ctx), + mode: defaultMode, + modeList: modeList, + } +} + +func (m *Manager) Name() string { + return "clash mode manager" +} + +func (m *Manager) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + cacheFile := service.FromContext[adapter.CacheFile](m.ctx) + if cacheFile != nil { + mode := cacheFile.LoadMode() + if common.Any(m.modeList, func(it string) bool { + return strings.EqualFold(it, mode) + }) { + m.mode = mode + } + } + return nil +} + +func (m *Manager) Close() error { + return nil +} + +func (m *Manager) Mode() string { + return m.mode +} + +func (m *Manager) ModeList() []string { + return m.modeList +} + +func (m *Manager) AddUpdateHook(hook *observable.Subscriber[struct{}]) { + m.updateAccess.Lock() + defer m.updateAccess.Unlock() + m.updateHooks = append(m.updateHooks, hook) +} + +func (m *Manager) SetMode(newMode string) { + if !common.Contains(m.modeList, newMode) { + newMode = common.Find(m.modeList, func(it string) bool { + return strings.EqualFold(it, newMode) + }) + } + if !common.Contains(m.modeList, newMode) { + return + } + if newMode == m.mode { + return + } + m.mode = newMode + m.updateAccess.Lock() + for _, hook := range m.updateHooks { + hook.Emit(struct{}{}) + } + m.updateAccess.Unlock() + m.dnsRouter.ClearCache() + cacheFile := service.FromContext[adapter.CacheFile](m.ctx) + if cacheFile != nil { + err := cacheFile.StoreMode(newMode) + if err != nil { + m.logger.Error(E.Cause(err, "save mode")) + } + } + m.logger.Notice("updated mode: ", newMode) +} + +var _ adapter.LifecycleService = (*Manager)(nil) diff --git a/experimental/clashmode/mode_list.go b/experimental/clashmode/mode_list.go new file mode 100644 index 00000000..e0c283e1 --- /dev/null +++ b/experimental/clashmode/mode_list.go @@ -0,0 +1,62 @@ +package clashmode + +import ( + "sort" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" +) + +func CalculateModeList(options option.Options) []string { + var clashModes []string + clashModes = append(clashModes, extraClashModeFromRule(common.PtrValueOrDefault(options.Route).Rules)...) + clashModes = append(clashModes, extraClashModeFromDNSRule(common.PtrValueOrDefault(options.DNS).Rules)...) + clashModes = common.FilterNotDefault(common.Uniq(clashModes)) + predefinedOrder := []string{ + "Rule", "Global", "Direct", + } + var newClashModes []string + for _, mode := range clashModes { + if !common.Contains(predefinedOrder, mode) { + newClashModes = append(newClashModes, mode) + } + } + sort.Strings(newClashModes) + for _, mode := range predefinedOrder { + if common.Contains(clashModes, mode) { + newClashModes = append(newClashModes, mode) + } + } + return newClashModes +} + +func extraClashModeFromRule(rules []option.Rule) []string { + var clashMode []string + for _, rule := range rules { + switch rule.Type { + case C.RuleTypeDefault: + if rule.DefaultOptions.ClashMode != "" { + clashMode = append(clashMode, rule.DefaultOptions.ClashMode) + } + case C.RuleTypeLogical: + clashMode = append(clashMode, extraClashModeFromRule(rule.LogicalOptions.Rules)...) + } + } + return clashMode +} + +func extraClashModeFromDNSRule(rules []option.DNSRule) []string { + var clashMode []string + for _, rule := range rules { + switch rule.Type { + case C.RuleTypeDefault: + if rule.DefaultOptions.ClashMode != "" { + clashMode = append(clashMode, rule.DefaultOptions.ClashMode) + } + case C.RuleTypeLogical: + clashMode = append(clashMode, extraClashModeFromDNSRule(rule.LogicalOptions.Rules)...) + } + } + return clashMode +} diff --git a/experimental/libbox/command_server.go b/experimental/libbox/command_server.go index 2391b74e..5f62d03d 100644 --- a/experimental/libbox/command_server.go +++ b/experimental/libbox/command_server.go @@ -36,6 +36,7 @@ type CommandServer struct { platformInterface PlatformInterface platformWrapper *platformInterfaceWrapper powerManager *powerreport.Manager + oomRecorder *oomkiller.Recorder grpcServer *grpc.Server listener net.Listener endPauseTimer *time.Timer @@ -83,12 +84,14 @@ func NewCommandServer(handler CommandServerHandler, platformInterface PlatformIn // GroupID: sGroupID, // SystemProxyEnabled: false, }) - reporter := &oomReporter{startedService: server.StartedService} - service.MustRegister[oomkiller.OOMReporter](ctx, reporter) + oomRecorder := oomkiller.NewRecorder(OOMRecorderOptions(server.StartedService)) + service.MustRegister[*oomkiller.Recorder](ctx, oomRecorder) + oomRecorder.Start() + server.oomRecorder = oomRecorder server.managedService = daemon.NewManagedService(daemon.ManagedServiceOptions{ Handler: (*platformHandler)(server), Debug: sDebug, - OOMReporter: reporter, + OOMRecorder: oomRecorder, }) if sPowerReportEnabled { err := powerManager.Start(PowerReportOptions(server.StartedService)) @@ -190,11 +193,15 @@ func (s *CommandServer) Start() error { } func (s *CommandServer) Close() { + if s.endPauseTimer != nil { + s.endPauseTimer.Stop() + } if s.grpcServer != nil { s.grpcServer.Stop() } common.Close(s.listener) s.StartedService.Close() + s.oomRecorder.Close() s.powerManager.Close() } @@ -260,13 +267,21 @@ func (s *CommandServer) Pause() { instance.PauseManager().DevicePause() if C.IsIos { if s.endPauseTimer == nil { - s.endPauseTimer = time.AfterFunc(time.Minute, instance.PauseManager().DeviceWake) + s.endPauseTimer = time.AfterFunc(time.Minute, s.endDevicePause) } else { s.endPauseTimer.Reset(time.Minute) } } } +func (s *CommandServer) endDevicePause() { + instance := s.StartedService.Instance() + if instance == nil || instance.PauseManager() == nil { + return + } + instance.PauseManager().DeviceWake() +} + func (s *CommandServer) Wake() { recorder := s.powerManager.Recorder() if recorder != nil { diff --git a/experimental/libbox/internal/oomprofile/oomprofile.go b/experimental/libbox/internal/oomprofile/oomprofile.go index 0728af8f..5d355337 100644 --- a/experimental/libbox/internal/oomprofile/oomprofile.go +++ b/experimental/libbox/internal/oomprofile/oomprofile.go @@ -7,7 +7,6 @@ import ( "io" "math" "os" - "path/filepath" "runtime" "sort" "strings" @@ -52,28 +51,27 @@ type labelMap struct { labelSet } -func WriteFile(destPath string, name string) (string, error) { +func WriteFile(filePath string, name string) error { writer, ok := profileWriters[name] if !ok { - return "", fmt.Errorf("unsupported profile %q", name) + return fmt.Errorf("unsupported profile %q", name) } - - filePath := filepath.Join(destPath, name+".pb") file, err := os.Create(filePath) if err != nil { - return "", err + return err } defer file.Close() - - if err := writer(file); err != nil { + err = writer(file) + if err != nil { _ = os.Remove(filePath) - return "", err + return err } - if err := file.Close(); err != nil { + err = file.Close() + if err != nil { _ = os.Remove(filePath) - return "", err + return err } - return filePath, nil + return nil } var profileWriters = map[string]func(io.Writer) error{ diff --git a/experimental/libbox/internal/runtimeinfo/goroutine_badlinkname.go b/experimental/libbox/internal/runtimeinfo/goroutine_badlinkname.go new file mode 100644 index 00000000..90b06054 --- /dev/null +++ b/experimental/libbox/internal/runtimeinfo/goroutine_badlinkname.go @@ -0,0 +1,172 @@ +//go:build badlinkname + +package runtimeinfo + +import ( + "bytes" + "cmp" + "reflect" + "runtime" + "slices" + "strconv" + "sync" + "sync/atomic" + "unsafe" +) + +// Mirrors runtime.g of the pinned Go toolchain up to goid; the offsets of gopc and startpc are +// discovered by probeGoroutine. + +type runtimeG struct { + stackLo uintptr + stackHi uintptr + _ [2]uintptr + _ [3]unsafe.Pointer + _ [6]uintptr + _ [4]uintptr + _ unsafe.Pointer + atomicstatus uint32 + _ uint32 + goid uint64 +} + +//go:linkname allgs runtime.allgs +var allgs []*runtimeG + +const probeScanBytes = 512 + +var ( + gopcOffset uintptr + startpcOffset uintptr + probeDone chan bool +) + +func (gp *runtimeG) word(offset uintptr) uintptr { + return *(*uintptr)(unsafe.Add(unsafe.Pointer(gp), offset)) +} + +func (gp *runtimeG) gopc() uintptr { + return gp.word(gopcOffset) +} + +func (gp *runtimeG) startpc() uintptr { + return gp.word(startpcOffset) +} + +const ( + statusDead = 6 + statusScan = 0x1000 +) + +var statusNames = map[uint32]string{ + 0: "idle", + 1: "runnable", + 2: "running", + 3: "syscall", + 4: "waiting", + 6: "dead", + 8: "copystack", + 9: "preempted", +} + +var ( + layoutOnce sync.Once + layoutVerified bool +) + +func probeLayout() { + probeDone = make(chan bool, 1) + go probeGoroutine() + layoutVerified = <-probeDone +} + +func probeGoroutine() { + id := currentGoroutineID() + var self *runtimeG + for _, gp := range allgs { + if gp.goid == id { + self = gp + break + } + } + if self == nil { + probeDone <- false + return + } + creatorName := functionName(reflect.ValueOf(probeLayout).Pointer()) + selfName := functionName(reflect.ValueOf(probeGoroutine).Pointer()) + goidOffset := unsafe.Offsetof(self.goid) + for offset := goidOffset + 8; offset < goidOffset+probeScanBytes; offset += 8 { + name := functionName(self.word(offset)) + if gopcOffset == 0 && name == creatorName { + gopcOffset = offset + } else if startpcOffset == 0 && name == selfName { + startpcOffset = offset + } + } + probeDone <- gopcOffset != 0 && startpcOffset != 0 +} + +func functionName(pc uintptr) string { + function := runtime.FuncForPC(pc) + if function == nil { + return "unknown" + } + return function.Name() +} + +func currentGoroutineID() uint64 { + var stackBuffer [64]byte + n := runtime.Stack(stackBuffer[:], false) + fields := bytes.Fields(stackBuffer[:n]) + if len(fields) < 2 { + return 0 + } + id, _ := strconv.ParseUint(string(fields[1]), 10, 64) + return id +} + +func collectGoroutines() *GoroutineReport { + layoutOnce.Do(probeLayout) + if !layoutVerified { + return nil + } + report := &GoroutineReport{ByStatus: make(map[string]int)} + groups := make(map[string]*GoroutineGroup) + for _, gp := range allgs { + status := atomic.LoadUint32(&gp.atomicstatus) &^ statusScan + stackSize := uint64(gp.stackHi - gp.stackLo) + if status == statusDead { + report.Dead++ + if gp.stackLo != 0 { + report.DeadStackBytes += stackSize + } + continue + } + report.Total++ + report.StackBytes += stackSize + statusName, known := statusNames[status] + if !known { + statusName = strconv.Itoa(int(status)) + } + report.ByStatus[statusName]++ + name := functionName(gp.startpc()) + group, found := groups[name] + if !found { + group = &GoroutineGroup{Function: name, CreatedBy: functionName(gp.gopc()), MinStackBytes: stackSize} + groups[name] = group + } + group.Count++ + group.StackBytes += stackSize + group.MaxStackBytes = max(group.MaxStackBytes, stackSize) + group.MinStackBytes = min(group.MinStackBytes, stackSize) + } + report.ByFunction = make([]GoroutineGroup, 0, len(groups)) + for _, group := range groups { + report.ByFunction = append(report.ByFunction, *group) + } + slices.SortFunc(report.ByFunction, func(a, b GoroutineGroup) int { + return cmp.Compare(b.StackBytes, a.StackBytes) + }) + return report +} diff --git a/experimental/libbox/internal/runtimeinfo/goroutine_stub.go b/experimental/libbox/internal/runtimeinfo/goroutine_stub.go new file mode 100644 index 00000000..e76ef398 --- /dev/null +++ b/experimental/libbox/internal/runtimeinfo/goroutine_stub.go @@ -0,0 +1,7 @@ +//go:build !badlinkname + +package runtimeinfo + +func collectGoroutines() *GoroutineReport { + return nil +} diff --git a/experimental/libbox/internal/runtimeinfo/pool_badlinkname.go b/experimental/libbox/internal/runtimeinfo/pool_badlinkname.go new file mode 100644 index 00000000..3d4b9867 --- /dev/null +++ b/experimental/libbox/internal/runtimeinfo/pool_badlinkname.go @@ -0,0 +1,81 @@ +//go:build badlinkname + +package runtimeinfo + +import ( + "reflect" + "sync" + "sync/atomic" + "unsafe" + + "github.com/sagernet/sing/common/buf" +) + +// Mirrors sync.Pool, sync.poolLocal and sync.poolChainElt of the pinned Go toolchain. + +type syncPool struct { + local unsafe.Pointer + localSize uintptr + victim unsafe.Pointer + victimSize uintptr + _ func() any +} + +type poolLocal struct { + private [2]unsafe.Pointer + sharedHead unsafe.Pointer + _ unsafe.Pointer + _ [128 - 4*unsafe.Sizeof(uintptr(0))]byte +} + +type poolChainElt struct { + headTail uint64 + _ []unsafe.Pointer + _ unsafe.Pointer + prev unsafe.Pointer +} + +func collectBufferPools() []PoolReport { + allocator := reflect.ValueOf(buf.DefaultAllocator) + if allocator.Kind() != reflect.Pointer { + return nil + } + pools := allocator.Elem().FieldByName("buffers") + if !pools.IsValid() || pools.Kind() != reflect.Array || pools.Type().Elem() != reflect.TypeFor[sync.Pool]() { + return nil + } + if unsafe.Sizeof(syncPool{}) != unsafe.Sizeof(sync.Pool{}) || unsafe.Sizeof(poolLocal{}) != 128 { + return nil + } + result := make([]PoolReport, 0, pools.Len()) + for i := range pools.Len() { + pool := (*syncPool)(pools.Index(i).Addr().UnsafePointer()) + size := min(1<<(6+i), buf.MaxPooledBufferSize) + report := PoolReport{ + Size: size, + Cached: countPoolLocals(pool.local, pool.localSize), + Victim: countPoolLocals(pool.victim, pool.victimSize), + } + report.Bytes = uint64(report.Cached+report.Victim) * uint64(size) + result = append(result, report) + } + return result +} + +func countPoolLocals(locals unsafe.Pointer, count uintptr) int { + if locals == nil { + return 0 + } + var total int + for i := range count { + local := (*poolLocal)(unsafe.Add(locals, i*unsafe.Sizeof(poolLocal{}))) + if local.private[0] != nil { + total++ + } + for element := (*poolChainElt)(atomic.LoadPointer(&local.sharedHead)); element != nil; element = (*poolChainElt)(atomic.LoadPointer(&element.prev)) { + headTail := atomic.LoadUint64(&element.headTail) + total += int(uint32(headTail>>32) - uint32(headTail)) + } + } + return total +} diff --git a/experimental/libbox/internal/runtimeinfo/pool_stub.go b/experimental/libbox/internal/runtimeinfo/pool_stub.go new file mode 100644 index 00000000..f5db1c6d --- /dev/null +++ b/experimental/libbox/internal/runtimeinfo/pool_stub.go @@ -0,0 +1,7 @@ +//go:build !badlinkname + +package runtimeinfo + +func collectBufferPools() []PoolReport { + return nil +} diff --git a/experimental/libbox/internal/runtimeinfo/runtimeinfo.go b/experimental/libbox/internal/runtimeinfo/runtimeinfo.go new file mode 100644 index 00000000..b3a9b187 --- /dev/null +++ b/experimental/libbox/internal/runtimeinfo/runtimeinfo.go @@ -0,0 +1,74 @@ +package runtimeinfo + +import ( + "encoding/json" + "os" + "runtime/metrics" +) + +type Report struct { + Metrics map[string]uint64 `json:"metrics"` + Goroutines *GoroutineReport `json:"goroutines,omitempty"` + Pools []PoolReport `json:"bufferPools,omitempty"` +} + +type GoroutineReport struct { + Total int `json:"total"` + StackBytes uint64 `json:"stackBytes"` + Dead int `json:"dead"` + DeadStackBytes uint64 `json:"deadStackBytes"` + ByStatus map[string]int `json:"byStatus"` + ByFunction []GoroutineGroup `json:"byFunction"` +} + +type GoroutineGroup struct { + Function string `json:"function"` + CreatedBy string `json:"createdBy,omitempty"` + Count int `json:"count"` + StackBytes uint64 `json:"stackBytes"` + MaxStackBytes uint64 `json:"maxStackBytes"` + MinStackBytes uint64 `json:"minStackBytes"` +} + +type PoolReport struct { + Size int `json:"size"` + Cached int `json:"cached"` + Victim int `json:"victim"` + Bytes uint64 `json:"bytes"` +} + +func Collect() Report { + return Report{ + Metrics: collectMetrics(), + Goroutines: collectGoroutines(), + Pools: collectBufferPools(), + } +} + +func WriteFile(path string) error { + content, err := json.MarshalIndent(Collect(), "", " ") + if err != nil { + return err + } + return os.WriteFile(path, content, 0o666) +} + +func collectMetrics() map[string]uint64 { + descriptions := metrics.All() + samples := make([]metrics.Sample, 0, len(descriptions)) + for _, description := range descriptions { + if description.Kind != metrics.KindUint64 { + continue + } + samples = append(samples, metrics.Sample{Name: description.Name}) + } + metrics.Read(samples) + result := make(map[string]uint64, len(samples)) + for _, sample := range samples { + if sample.Value.Kind() != metrics.KindUint64 { + continue + } + result[sample.Name] = sample.Value.Uint64() + } + return result +} diff --git a/experimental/libbox/monitor.go b/experimental/libbox/monitor.go index 49f7702a..273b1275 100644 --- a/experimental/libbox/monitor.go +++ b/experimental/libbox/monitor.go @@ -56,6 +56,18 @@ func (m *platformDefaultInterfaceMonitor) UnregisterCallback(element *list.Eleme m.callbacks.Remove(element) } +func (m *platformDefaultInterfaceMonitor) UpdateNetworkPath(networkPath string) { + m.logger.Debug("updated network path: ", networkPath) + if m.powerManager == nil { + return + } + recorder := m.powerManager.Recorder() + if recorder == nil { + return + } + recorder.UpdateNetworkPath(networkPath) +} + func (m *platformDefaultInterfaceMonitor) UpdateDefaultInterface(interfaceName string, interfaceIndex32 int32, isExpensive bool, isConstrained bool) { if sFixAndroidStack { done := make(chan struct{}) diff --git a/experimental/libbox/oom_report.go b/experimental/libbox/oom_report.go index d9bbbdd5..6cccdb46 100644 --- a/experimental/libbox/oom_report.go +++ b/experimental/libbox/oom_report.go @@ -5,21 +5,18 @@ package libbox import ( "bytes" "encoding/json" - "os" "path/filepath" - "runtime" "sort" - "strings" "time" "github.com/sagernet/sing-box/common/trafficcontrol" "github.com/sagernet/sing-box/daemon" "github.com/sagernet/sing-box/experimental/libbox/internal/oomprofile" + "github.com/sagernet/sing-box/experimental/libbox/internal/runtimeinfo" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/service/oomkiller" "github.com/sagernet/sing/common/byteformats" F "github.com/sagernet/sing/common/format" - "github.com/sagernet/sing/common/memory" ) var oomReportProfiles = []string{ @@ -34,157 +31,66 @@ 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"` - // Heap - HeapAlloc string `json:"heapAlloc,omitempty"` - HeapObjects uint64 `json:"heapObjects,omitempty,string"` - HeapInuse string `json:"heapInuse,omitempty"` - HeapIdle string `json:"heapIdle,omitempty"` - HeapReleased string `json:"heapReleased,omitempty"` - HeapSys string `json:"heapSys,omitempty"` - // Stack - StackInuse string `json:"stackInuse,omitempty"` - StackSys string `json:"stackSys,omitempty"` - // Runtime metadata - MSpanInuse string `json:"mSpanInuse,omitempty"` - MSpanSys string `json:"mSpanSys,omitempty"` - MCacheSys string `json:"mCacheSys,omitempty"` - BuckHashSys string `json:"buckHashSys,omitempty"` - GCSys string `json:"gcSys,omitempty"` - OtherSys string `json:"otherSys,omitempty"` - Sys string `json:"sys,omitempty"` - // GC & runtime - TotalAlloc string `json:"totalAlloc,omitempty"` - NumGC uint32 `json:"numGC,omitempty,string"` - NumGoroutine int `json:"numGoroutine,omitempty,string"` - NextGC string `json:"nextGC,omitempty"` - LastGC string `json:"lastGC,omitempty"` + Snapshots int `json:"snapshots,omitempty,string"` } -type oomReporter struct { - startedService *daemon.StartedService +func OOMRecorderOptions(startedService *daemon.StartedService) oomkiller.RecorderOptions { + return oomkiller.RecorderOptions{ + BasePath: sWorkingPath, + Logger: log.StdLogger(), + AcceptDraft: acceptOOMDraft, + MetadataCallback: func(status oomkiller.ReportStatus) any { + metadata := oomReportMetadata{ + reportMetadata: baseReportMetadata(), + RecordedAt: status.RecordedAt.UTC().Format(time.RFC3339), + MemoryUsage: byteformats.FormatMemoryBytes(status.PeakMemory), + 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) + } + if status.AvailableKnown { + metadata.AvailableMemory = byteformats.FormatMemoryBytes(status.MinAvailable) + } + return metadata + }, + OwnerCallback: chownReport, + LogCallback: func() []byte { + return formatLogEntries(startedService.SavedLog()) + }, + SnapshotCallback: func(directory string, prefix string) { + for _, name := range oomReportProfiles { + writeOOMProfile(filepath.Join(directory, prefix+"."+name+".pb"), name) + } + runtimeInfoPath := filepath.Join(directory, prefix+".runtime.json") + err := runtimeinfo.WriteFile(runtimeInfoPath) + if err == nil { + chownReport(runtimeInfoPath) + } + copyConfigSnapshot(directory) + content := oomConnectionsContent(startedService) + if content != nil { + writeReportFile(directory, prefix+".connections.json", content) + } + }, + } } -var _ oomkiller.OOMReporter = (*oomReporter)(nil) - -func NewOOMReporter(startedService *daemon.StartedService) oomkiller.OOMReporter { - return &oomReporter{startedService: startedService} -} - -func (r *oomReporter) WriteReport(memoryUsage uint64) error { - draftPath := filepath.Join(sWorkingPath, "oom_draft") - draftInfo, err := os.Stat(draftPath) +func acceptOOMDraft(metadataContent []byte) bool { + var draftMetadata reportMetadata + err := json.Unmarshal(metadataContent, &draftMetadata) if err != nil { - if !os.IsNotExist(err) { - return err - } - draftInfo = nil + return false } - reportsDir := filepath.Join(sWorkingPath, "oom_reports") - err = os.MkdirAll(reportsDir, 0o777) - if err != nil { - return err - } - chownReport(reportsDir) - - destPath, err := nextAvailableReportPath(reportsDir, time.Now().UTC()) - if err != nil { - return err - } - err = r.writeSnapshot(destPath, memoryUsage) - if err != nil { - return err - } - return discardDraftIfCurrent(draftPath, draftInfo) -} - -func (r *oomReporter) WriteDraft(memoryUsage uint64) error { - draftPath := filepath.Join(sWorkingPath, "oom_draft") - os.RemoveAll(draftPath) - return r.writeSnapshot(draftPath, memoryUsage) -} - -func (r *oomReporter) DiscardDraft() error { - draftPath := filepath.Join(sWorkingPath, "oom_draft") - return os.RemoveAll(draftPath) -} - -func discardDraftIfCurrent(draftPath string, draftInfo os.FileInfo) error { - if draftInfo == nil { - return nil - } - currentInfo, err := os.Stat(draftPath) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - if !os.SameFile(draftInfo, currentInfo) { - return nil - } - return os.RemoveAll(draftPath) -} - -func (r *oomReporter) writeSnapshot(destPath string, memoryUsage uint64) error { - now := time.Now().UTC() - err := os.MkdirAll(destPath, 0o777) - if err != nil { - return err - } - chownReport(destPath) - - for _, name := range oomReportProfiles { - writeOOMProfile(destPath, name) - } - - writeReportFile(destPath, "cmdline", []byte(strings.Join(os.Args, "\000"))) - - var memStats runtime.MemStats - runtime.ReadMemStats(&memStats) - - metadata := oomReportMetadata{ - reportMetadata: baseReportMetadata(), - RecordedAt: now.Format(time.RFC3339), - MemoryUsage: byteformats.FormatMemoryBytes(memoryUsage), - // Heap - HeapAlloc: byteformats.FormatMemoryBytes(memStats.HeapAlloc), - HeapObjects: memStats.HeapObjects, - HeapInuse: byteformats.FormatMemoryBytes(memStats.HeapInuse), - HeapIdle: byteformats.FormatMemoryBytes(memStats.HeapIdle), - HeapReleased: byteformats.FormatMemoryBytes(memStats.HeapReleased), - HeapSys: byteformats.FormatMemoryBytes(memStats.HeapSys), - // Stack - StackInuse: byteformats.FormatMemoryBytes(memStats.StackInuse), - StackSys: byteformats.FormatMemoryBytes(memStats.StackSys), - // Runtime metadata - MSpanInuse: byteformats.FormatMemoryBytes(memStats.MSpanInuse), - MSpanSys: byteformats.FormatMemoryBytes(memStats.MSpanSys), - MCacheSys: byteformats.FormatMemoryBytes(memStats.MCacheSys), - BuckHashSys: byteformats.FormatMemoryBytes(memStats.BuckHashSys), - GCSys: byteformats.FormatMemoryBytes(memStats.GCSys), - OtherSys: byteformats.FormatMemoryBytes(memStats.OtherSys), - Sys: byteformats.FormatMemoryBytes(memStats.Sys), - // GC & runtime - TotalAlloc: byteformats.FormatMemoryBytes(memStats.TotalAlloc), - NumGC: memStats.NumGC, - NumGoroutine: runtime.NumGoroutine(), - NextGC: byteformats.FormatMemoryBytes(memStats.NextGC), - } - if memStats.LastGC > 0 { - metadata.LastGC = time.Unix(0, int64(memStats.LastGC)).UTC().Format(time.RFC3339) - } - availableMemory := memory.Available() - if availableMemory > 0 { - metadata.AvailableMemory = byteformats.FormatMemoryBytes(availableMemory) - } - writeReportMetadata(destPath, metadata) - copyConfigSnapshot(destPath) - writeOOMLog(destPath, r.startedService.SavedLog()) - r.writeOOMConnections(destPath) - - return nil + return draftMetadata.AppVersion == sAppVersion && draftMetadata.AppMarketingVersion == sAppMarketingVersion } type oomConnectionsInfo struct { @@ -213,14 +119,14 @@ type oomConnectionInfo struct { Download string `json:"download,omitempty"` } -func (r *oomReporter) writeOOMConnections(destPath string) { - instance := r.startedService.Instance() +func oomConnectionsContent(startedService *daemon.StartedService) []byte { + instance := startedService.Instance() if instance == nil { - return + return nil } trafficManager := instance.TrafficManager() if trafficManager == nil { - return + return nil } connections := trafficManager.Connections() sort.Slice(connections, func(i, j int) bool { @@ -235,9 +141,9 @@ func (r *oomReporter) writeOOMConnections(destPath string) { } data, err := json.MarshalIndent(info, "", " ") if err != nil { - return + return nil } - writeReportFile(destPath, "connections.json", data) + return data } func buildOOMConnections(connections []*trafficcontrol.TrackerMetadata) []oomConnectionInfo { @@ -314,14 +220,6 @@ func formatLogEntries(entries []*log.Entry) []byte { return buffer.Bytes() } -func writeOOMLog(destPath string, entries []*log.Entry) { - content := formatLogEntries(entries) - if content == nil { - return - } - writeReportFile(destPath, "go.log", content) -} - func writeWithoutColors(buffer *bytes.Buffer, message string) { start := 0 for index := 0; index < len(message); { @@ -343,54 +241,18 @@ func writeWithoutColors(buffer *bytes.Buffer, message string) { buffer.WriteString(message[start:]) } -func writeOOMProfile(destPath string, name string) { - filePath, err := oomprofile.WriteFile(destPath, name) +func writeOOMProfile(filePath string, name string) { + err := oomprofile.WriteFile(filePath, name) if err != nil { return } chownReport(filePath) } -func promoteOOMDraftAt(workingPath string) { - draftPath := filepath.Join(workingPath, "oom_draft") - info, err := os.Stat(draftPath) - if err != nil || !info.IsDir() { - return - } - metadataContent, err := os.ReadFile(filepath.Join(draftPath, "metadata.json")) - if err != nil { - os.RemoveAll(draftPath) - return - } - var draftMetadata reportMetadata - err = json.Unmarshal(metadataContent, &draftMetadata) - if err != nil || draftMetadata.AppVersion != sAppVersion || draftMetadata.AppMarketingVersion != sAppMarketingVersion { - os.RemoveAll(draftPath) - return - } - reportsDir := filepath.Join(workingPath, "oom_reports") - initReportDir(reportsDir) - destPath, err := nextAvailableReportPath(reportsDir, info.ModTime().UTC()) - if err != nil { - os.RemoveAll(draftPath) - return - } - err = os.Rename(draftPath, destPath) - if err != nil { - os.RemoveAll(draftPath) - return - } - chownReport(destPath) -} - -func promoteOOMDraft() { - promoteOOMDraftAt(sWorkingPath) -} - func PromoteOOMDraft() { - promoteOOMDraft() + oomkiller.PromoteDraft(sWorkingPath, acceptOOMDraft) } func PromoteOOMDraftAt(workingPath string) { - promoteOOMDraftAt(workingPath) + oomkiller.PromoteDraft(workingPath, acceptOOMDraft) } diff --git a/experimental/libbox/platform.go b/experimental/libbox/platform.go index 796bee4e..aae27cd9 100644 --- a/experimental/libbox/platform.go +++ b/experimental/libbox/platform.go @@ -90,6 +90,7 @@ func (c *ConnectionOwner) AndroidPackageNames() StringIterator { type InterfaceUpdateListener interface { UpdateDefaultInterface(interfaceName string, interfaceIndex int32, isExpensive bool, isConstrained bool) + UpdateNetworkPath(networkPath string) } const ( diff --git a/experimental/libbox/power_report.go b/experimental/libbox/power_report.go index d78c72b1..29648cf6 100644 --- a/experimental/libbox/power_report.go +++ b/experimental/libbox/power_report.go @@ -3,6 +3,7 @@ package libbox import ( + "path/filepath" "time" "github.com/sagernet/sing-box/daemon" @@ -29,7 +30,7 @@ func PowerReportOptions(startedService *daemon.StartedService) powerreport.Optio }, ProfileCallback: func(path string) { for _, name := range oomReportProfiles { - writeOOMProfile(path, name) + writeOOMProfile(filepath.Join(path, name+".pb"), name) } }, } diff --git a/experimental/libbox/setup.go b/experimental/libbox/setup.go index 9c3390ad..a78cba54 100644 --- a/experimental/libbox/setup.go +++ b/experimental/libbox/setup.go @@ -92,6 +92,7 @@ func ReloadSetupOptions(options *SetupOptions) { if sOOMKillerEnabled { if sOOMMemoryLimit == 0 && C.IsIos { sOOMMemoryLimit = oomkiller.DefaultAppleNetworkExtensionMemoryLimit + debug.SetGCPercent(oomkiller.DefaultAppleNetworkExtensionGCPercent) } if sOOMMemoryLimit > 0 { debug.SetMemoryLimit(sOOMMemoryLimit * 4 / 5) diff --git a/go.mod b/go.mod index c73e29a8..23092463 100644 --- a/go.mod +++ b/go.mod @@ -50,8 +50,8 @@ require ( github.com/sagernet/asc-go v0.0.0-20260827085112-8f8226245b0d github.com/sagernet/bbolt v0.0.0-20260823094646-e24805439c9c github.com/sagernet/cors v1.2.1 - github.com/sagernet/cronet-go v0.0.0-20260831031307-45832ab07484 - github.com/sagernet/cronet-go/all v0.0.0-20260831031307-45832ab07484 + github.com/sagernet/cronet-go v0.0.0-20260912104727-0d28acc44093 + github.com/sagernet/cronet-go/all v0.0.0-20260912104727-0d28acc44093 github.com/sagernet/fswatch v0.1.2 github.com/sagernet/gliderssh v0.3.4-0.20260531100337-2194faca5648 github.com/sagernet/gomobile v0.1.12 @@ -59,22 +59,22 @@ 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.0-beta.4 + github.com/sagernet/sing v0.9.4 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 - github.com/sagernet/sing-mux v0.3.5 + github.com/sagernet/sing-mux v0.3.6 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-beta.4 + github.com/sagernet/sing-quic v0.7.0 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-tun v0.9.0-beta.4 + github.com/sagernet/sing-tun v0.9.3 github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca - github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 + 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.4 - github.com/sagernet/wireguard-go v0.0.5-extended-1.6.1 + github.com/sagernet/tailscale v1.102.1-sing-box-1.14-mod.5 + github.com/sagernet/wireguard-go v0.0.6-extended-1.6.1 github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.0.2 github.com/shtorm-7/workerpool v0.5.0 @@ -211,35 +211,35 @@ require ( github.com/pires/go-proxyproto v0.11.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/safchain/ethtool v0.3.0 // indirect - github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260831030607-f80ef37265e5 // indirect - github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260831030607-f80ef37265e5 // indirect + github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260912104006-c10c03c318db // indirect + github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260912104006-c10c03c318db // indirect github.com/shtorm-7/rmux v1.0.1 // indirect github.com/smallstep/pkcs7 v0.1.1 // indirect github.com/spf13/pflag v1.0.10 // indirect @@ -270,9 +270,9 @@ require ( zombiezen.com/go/capnproto2 v2.18.2+incompatible // indirect ) -replace github.com/sagernet/wireguard-go => github.com/shtorm-7/wireguard-go v0.0.5-extended-1.6.1 +replace github.com/sagernet/wireguard-go => github.com/shtorm-7/wireguard-go v0.0.6-extended-1.6.1 -replace github.com/sagernet/tailscale => github.com/shtorm-7/tailscale v1.102.1-sing-box-1.14-mod.4-extended-1.0.3 +replace github.com/sagernet/tailscale => github.com/shtorm-7/tailscale v1.102.1-sing-box-1.14-mod.5-extended-1.0.3 replace github.com/ameshkov/dnscrypt/v2 => github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0 diff --git a/go.sum b/go.sum index 9f6e59a7..f456520e 100644 --- a/go.sum +++ b/go.sum @@ -395,68 +395,68 @@ github.com/sagernet/bbolt v0.0.0-20260823094646-e24805439c9c h1:JdEDkgiwsDxiZb4+ github.com/sagernet/bbolt v0.0.0-20260823094646-e24805439c9c/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-20260831031307-45832ab07484 h1:m9mp6XyetfRKGwhJ3n4/48nIV4BK8wtY/ox31o2uXbo= -github.com/sagernet/cronet-go v0.0.0-20260831031307-45832ab07484/go.mod h1:T/mwtrpC4JlWfScw73CmSBvHzIvc7BatQ1MhRr+cYNw= -github.com/sagernet/cronet-go/all v0.0.0-20260831031307-45832ab07484 h1:K0ZZ6A1l6++yAmdlBmk6/uFWB9u3wiiH06hJdRK3760= -github.com/sagernet/cronet-go/all v0.0.0-20260831031307-45832ab07484/go.mod h1:A+ICEP6DN5hO2aIXKuQ9PedwwngwRQUSul2QO2oNS4w= -github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260831030607-f80ef37265e5 h1:YB3mwkD8kJgUv1Pyt9ULE53s6ZgPoqUZk0UPsPlU91g= -github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260831030607-f80ef37265e5/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= -github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260831030607-f80ef37265e5 h1:1fk5mfJVwhfBa1I8q5e8NLqxpuPC3kZEijf+fzjzUAc= -github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260831030607-f80ef37265e5/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= -github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260831030607-f80ef37265e5 h1:Iyp6sZ1NqKdEe8BTOg0jF9PAlB+jz6Mr+MYc8hqxl+4= -github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260831030607-f80ef37265e5/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= -github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260831030607-f80ef37265e5 h1:u0r5kq5fjvOhrlA1rpqNhhwXP9OdNk7xWy8qH2NfPc4= -github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260831030607-f80ef37265e5/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= -github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260831030607-f80ef37265e5 h1:Qevs2qJ3OzSxWpeDouT+VPI/RwhR8S35WdaWB0wGwUk= -github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260831030607-f80ef37265e5/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= -github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260831030607-f80ef37265e5 h1:ab6tfSgqfsiKvxrfZNLGHwvrB5xG66YJa8N3Xy/3gHw= -github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260831030607-f80ef37265e5/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= -github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260831030607-f80ef37265e5 h1:DECxuNWiZf/SI+dHOyux4zIQSpX7Rx44ZgRqee3L79A= -github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260831030607-f80ef37265e5/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= -github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260831030607-f80ef37265e5 h1:8vMHErGz1Ut/CDIlFGBFYxrQtss7fG232e7yPleZUcY= -github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260831030607-f80ef37265e5/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= -github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260831030607-f80ef37265e5 h1:i41nGgyv2O7ytxhxQ8YZ++VrY2m6bPplqK/v/kUCRr4= -github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260831030607-f80ef37265e5/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= -github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260831030607-f80ef37265e5 h1:u1xfank4+oUEhSWRRcKhK2vvykLFoh+IFHjPw4EV6K8= -github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260831030607-f80ef37265e5/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= -github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260831030607-f80ef37265e5 h1:6vWlr+2OvV0vIzkg/R7ny+7TnGZ45WBDK+IDm7pAbMk= -github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260831030607-f80ef37265e5/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= -github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260831030607-f80ef37265e5 h1:aE/JycRNXzewRfR801aCoWNvuwaCylQqHSBSNI+QT+g= -github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260831030607-f80ef37265e5/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= -github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260831030607-f80ef37265e5 h1:EjTX6AErgO9R7/geSuydPmI8zo2rjA0+HOhAM8DQN0M= -github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260831030607-f80ef37265e5/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= -github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260831030607-f80ef37265e5 h1:e7mwyBfKSUeTjD+W9HTMCVFmVcXUzqq9AyJGXB8ViAw= -github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260831030607-f80ef37265e5/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= -github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260831030607-f80ef37265e5 h1:B+qkPThRPgPAhtLF/r9DoYAfic++/MjBSZ+trl7LPNk= -github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260831030607-f80ef37265e5/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= -github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260831030607-f80ef37265e5 h1:FKfBZP4GPVgT8oECoY2BtYHi//Y0Y/rt3z1I76d6thg= -github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260831030607-f80ef37265e5/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= -github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260831030607-f80ef37265e5 h1:DffAWw6kqf37at38oABCgjqnFjjDk85RYiud7gqN62M= -github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260831030607-f80ef37265e5/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= -github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260831030607-f80ef37265e5 h1:x0wmFKQPGwD2Yv/OTmGDbNTEPwtc414NHqjegf1Uiw8= -github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260831030607-f80ef37265e5/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= -github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260831030607-f80ef37265e5 h1:ugUChFe1eVrSOTH6uBlfoIbGtyH3czlcHZ1Rvamjq28= -github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260831030607-f80ef37265e5/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= -github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260831030607-f80ef37265e5 h1:ii/FMyTM8Gpltwx93Q0c/YAnxwKMJ6Dhan6ZNs6p42g= -github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260831030607-f80ef37265e5/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= -github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260831030607-f80ef37265e5 h1:27omzPf1U+CkXgmL9N1YhJ2frJCQsiDNEhTIaGS+Xzg= -github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260831030607-f80ef37265e5/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= -github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260831030607-f80ef37265e5 h1:SdF9F+Dq0gKBD6STdx4ScdsyaSJkIjkU6V7gkX2VLHQ= -github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260831030607-f80ef37265e5/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= -github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260831030607-f80ef37265e5 h1:Pl3AjNnOXQuiynGrxBBTLi86RLYK7r7E+cnBVMks+iw= -github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260831030607-f80ef37265e5/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= -github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260831030607-f80ef37265e5 h1:dUyfzwer6H6AKycGoQOjdsdToGC4oqHv8peBK9q3OcY= -github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260831030607-f80ef37265e5/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= -github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260831030607-f80ef37265e5 h1:JJnON/3i+p9mfuLEZ8FOmynL43/LWmA81eu5V6eI1VM= -github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260831030607-f80ef37265e5/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= -github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260831030607-f80ef37265e5 h1:zgmvvztHMkS2poND9yjmpoBY0Rm56KVhX6+5+FU6wz0= -github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260831030607-f80ef37265e5/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= -github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260831030607-f80ef37265e5 h1:XSgRX0Pk77QZOhiUSBvuMpeW64FFMcFM0+aZQczxeX8= -github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260831030607-f80ef37265e5/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= -github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260831030607-f80ef37265e5 h1:lcNNmqbPlt4m7bBkD9xs6Nvm9R3GHjyhefnwLJrbLGQ= -github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260831030607-f80ef37265e5/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= -github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260831030607-f80ef37265e5 h1:gx9BY7k0JwB7ugdsp+zmY4vdNTIO9g+BkNhSBpNw7+k= -github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260831030607-f80ef37265e5/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= +github.com/sagernet/cronet-go v0.0.0-20260912104727-0d28acc44093 h1:CsvsLVIli53L6nzgEpyRjjWnRRhTds4D3eh5sEnF5Sw= +github.com/sagernet/cronet-go v0.0.0-20260912104727-0d28acc44093/go.mod h1:T/mwtrpC4JlWfScw73CmSBvHzIvc7BatQ1MhRr+cYNw= +github.com/sagernet/cronet-go/all v0.0.0-20260912104727-0d28acc44093 h1:1lRUqrLcBz0Q0r/xvyiXS5hZwH5pOq6Lwb4dqbSqJfs= +github.com/sagernet/cronet-go/all v0.0.0-20260912104727-0d28acc44093/go.mod h1:bvm9T9FdwHYYfWdZkLoZ4Qr9Dce0TTQ7ngXlbGZnnJk= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260912104006-c10c03c318db h1:o0NCKT9gZsOLJK5s5wXW5A5EaFCnF5En6SApmHwUMJo= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260912104006-c10c03c318db/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260912104006-c10c03c318db h1:75fzfzaz/j1mKsqNE01KTMMP3i/9FgmPlwO2BIV8TpU= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260912104006-c10c03c318db/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260912104006-c10c03c318db h1:tz6Tm3qqxPKp9QMFDUxbVzu5NgYQZq2hY9CGQ+sTmII= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260912104006-c10c03c318db/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260912104006-c10c03c318db h1:OO+rUNHeZja1m0n/bBfkWtK2NQkNN9iROP0CRqw9aL8= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260912104006-c10c03c318db/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260912104006-c10c03c318db h1:78W68WJdZjQDhkp4tcFkLcXBH4W8jlCBK1eFcBKKZwI= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260912104006-c10c03c318db/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260912104006-c10c03c318db h1:qVLzw+rWqzD82/CUwvxfSohUj6aSD9zRAX7WtKiSNeQ= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260912104006-c10c03c318db/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260912104006-c10c03c318db h1:EoaFEVn+GVZw2jKXgK+73BdpUZKJ85PGUdbAyxy72pk= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260912104006-c10c03c318db/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260912104006-c10c03c318db h1:rtv8UspnSr157Hp9k6Pj0IV224GvANVx+XTTd2/CjdY= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260912104006-c10c03c318db/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260912104006-c10c03c318db h1:TwBUmUKDuQzA5nIshSzVE0tCKK1GpEfy54E1h3o62t8= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260912104006-c10c03c318db/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260912104006-c10c03c318db h1:T2yA2pXtVKeZniYbp8bfcNTigauAl3EO3AO1UwVXoGE= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260912104006-c10c03c318db/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260912104006-c10c03c318db h1:w5BfjtEy9mjMiXtmKfB2uBGeRG4pwbuQ6ouTflNnWyU= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260912104006-c10c03c318db/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260912104006-c10c03c318db h1:yNdO+HC5OkgKgwqVYd49FRVt5A2jfxsyrSKrf2uM0aM= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260912104006-c10c03c318db/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260912104006-c10c03c318db h1:jJ1WycEj07LsIhtZdgXSiDa9qMkDUvBtr/B8WQm5utQ= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260912104006-c10c03c318db/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260912104006-c10c03c318db h1:HPBv0BqbHC8ktpn1rSMeoREDPumyFFpJHZsUCwzwLqY= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260912104006-c10c03c318db/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260912104006-c10c03c318db h1:7mJdu1gjC45rEkiIGtdCuEs3v1Oj2IXBnSh9uIgZEmE= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260912104006-c10c03c318db/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260912104006-c10c03c318db h1:YCBQCFTjlkRkkCXV+ScjN/URW9Mx58f6Dv34a8JRtGw= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260912104006-c10c03c318db/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260912104006-c10c03c318db h1:RIp7sKxgUGMidhmi7oVhX8DgnG6UjKk7kvxWvWfKvbY= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260912104006-c10c03c318db/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260912104006-c10c03c318db h1:lElglqZOqg3g7/L8uzJKS6yPNgIxbEu4/vVTzlYE7W8= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260912104006-c10c03c318db/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260912104006-c10c03c318db h1:4HvprIkwKcrPYuyQaH5tdIeh5KVBgkauL6o0VqpCkSo= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260912104006-c10c03c318db/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260912104006-c10c03c318db h1:rWrNWeyy563mTk3KZas3ore7JPq7M+l8r0XV9lAjc0c= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260912104006-c10c03c318db/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260912104006-c10c03c318db h1:YADxQVD/X9yyV3i/xS3T/lWhpVLslIIl+e+srwpT9p0= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260912104006-c10c03c318db/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260912104006-c10c03c318db h1:2hgFBooF0jPOGXKcuJgMA4h1aSOULyAI6OgubZBoD7g= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260912104006-c10c03c318db/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260912104006-c10c03c318db h1:fFv7izOOsC/HQCDAwTtPjIlayo8znNlAnPqppDb08Jo= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260912104006-c10c03c318db/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260912104006-c10c03c318db h1:meCf6CbZqWpV5ITTjawLJzZfFrx6thSgs7a9PZyqdTM= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260912104006-c10c03c318db/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260912104006-c10c03c318db h1:7bOFA2F/WApDRUamwGM5OThrY5H3+UZpW0yQLlukE8Y= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260912104006-c10c03c318db/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260912104006-c10c03c318db h1:9HjQtfjpo5PTr5AMBWgyTVnWI/zQOTDVQ1agftOOoGo= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260912104006-c10c03c318db/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260912104006-c10c03c318db h1:9OUQzCcoCvleQVLb3xW3AGB+eKGxPmldK1gg61xh7T8= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260912104006-c10c03c318db/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260912104006-c10c03c318db h1:fKs7KaruaDhStaiYpaqMQXDCmAc4YmNLI/pBkPYvwH8= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260912104006-c10c03c318db/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260912104006-c10c03c318db h1:K7/F1mWxmf5yUDxSSk+6pRk+yfCoWsqIgt2LsjRpP8w= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260912104006-c10c03c318db/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= github.com/sagernet/fswatch v0.1.2 h1:/TT7k4mkce1qFPxamLO842WjqBgbTBiXP2mlUjp9PFk= github.com/sagernet/fswatch v0.1.2/go.mod h1:5BpGmpUQVd3Mc5r313HRpvADHRg3/rKn5QbwFteB880= github.com/sagernet/gliderssh v0.3.4-0.20260531100337-2194faca5648 h1:IWVjKBARzVjdmH0VUaeTBOBli1qkwKmTG4XfbkpSS20= @@ -477,8 +477,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-beta.4 h1:frzDiZqUbT0yLVaVfgaLtoVQ/zGa3xIsDTHP+aSM/W8= -github.com/sagernet/sing-quic v0.7.0-beta.4/go.mod h1:rJFeVxbEej2iuyeCJ3drKW+p5rOgtLIW3XhZ6ty7Fig= +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-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= @@ -487,8 +487,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.0-beta.4 h1:gIIZU4HevhtTQubZiOdMD0/RnECD6csl2kG6666KYmQ= -github.com/sagernet/sing-tun v0.9.0-beta.4/go.mod h1:3EgPst7agntRO7D6GOsiZ1l9FoqdLeuWmKT5TnWkmf0= +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-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/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478= @@ -511,10 +511,10 @@ github.com/shtorm-7/sing-mux v0.3.5-extended-1.0.0 h1:AfCNqpQlrdWnEd64wXN+cmoHlP github.com/shtorm-7/sing-mux v0.3.5-extended-1.0.0/go.mod h1:1qHy7cnbDQSY2OWL7CybproWC0t5Ia/DIuNHAzB6ZCA= github.com/shtorm-7/sing-vmess v0.2.8-extended-1.0.0 h1:OjvqZOhYSi9eIJGYC0iPDPqvgo/asujvhAGkFzbZ5/Y= github.com/shtorm-7/sing-vmess v0.2.8-extended-1.0.0/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY= -github.com/shtorm-7/tailscale v1.102.1-sing-box-1.14-mod.4-extended-1.0.3 h1:AG4leaIK/VByNXDCPtxtBjLc1orPAtBBNcfw+rebPno= -github.com/shtorm-7/tailscale v1.102.1-sing-box-1.14-mod.4-extended-1.0.3/go.mod h1:+1R9TnmCDbxb7h7ZOBKxi5UI6aWf+polOO/fc0WB/4o= -github.com/shtorm-7/wireguard-go v0.0.5-extended-1.6.1 h1:lLd8c5SNbm01idBlaSc18Urn4i4/ME/Umu8BeQLJ/io= -github.com/shtorm-7/wireguard-go v0.0.5-extended-1.6.1/go.mod h1:1VZmkU7yPAZVE0XKufUqqAhEY1RnWUcyZLmry85y+7A= +github.com/shtorm-7/tailscale v1.102.1-sing-box-1.14-mod.5-extended-1.0.3 h1:cji5tRY667BMIwlzHvuIf51J2H/c8WPZYoOFNDtbbsA= +github.com/shtorm-7/tailscale v1.102.1-sing-box-1.14-mod.5-extended-1.0.3/go.mod h1:+1R9TnmCDbxb7h7ZOBKxi5UI6aWf+polOO/fc0WB/4o= +github.com/shtorm-7/wireguard-go v0.0.6-extended-1.6.1 h1:aPKSNxyu5VGqSLvDKoNRP/lnSCxS2sRKuzxDBWX99wo= +github.com/shtorm-7/wireguard-go v0.0.6-extended-1.6.1/go.mod h1:1VZmkU7yPAZVE0XKufUqqAhEY1RnWUcyZLmry85y+7A= github.com/shtorm-7/workerpool v0.5.0 h1:NPZuNgyH0EUm4aQsTL09xR1iV+7GCFw6jX9Z4aAVp2s= github.com/shtorm-7/workerpool v0.5.0/go.mod h1:NI0pUZgmGu0BdKO9j3mct1DNZmgXbyTS9foorljdH6E= github.com/smallstep/pkcs7 v0.1.1 h1:x+rPdt2W088V9Vkjho4KtoggyktZJlMduZAtRHm68LU= diff --git a/include/clashapi_stub.go b/include/clashapi_stub.go index e7d5304f..53f78148 100644 --- a/include/clashapi_stub.go +++ b/include/clashapi_stub.go @@ -13,7 +13,7 @@ import ( ) func init() { - experimental.RegisterClashServerConstructor(func(ctx context.Context, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.ClashServer, error) { + experimental.RegisterClashServerConstructor(func(ctx context.Context, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.LifecycleService, error) { return nil, E.New(`clash api is not included in this build, rebuild with -tags with_clash_api`) }) } diff --git a/log/factory.go b/log/factory.go index b73fb236..9c1921a3 100644 --- a/log/factory.go +++ b/log/factory.go @@ -17,12 +17,12 @@ type Factory interface { SetLevel(level Level) Logger() ContextLogger NewLogger(tag string) ContextLogger + AttachPlatformWriter(writer PlatformWriter) } type ObservableFactory interface { Factory observable.Observable[Entry] - AttachPlatformWriter(writer PlatformWriter) } type Entry struct { diff --git a/log/format.go b/log/format.go index 9d8176f7..13ed42b4 100644 --- a/log/format.go +++ b/log/format.go @@ -6,8 +6,6 @@ import ( "strings" "time" - F "github.com/sagernet/sing/common/format" - "github.com/logrusorgru/aurora" ) @@ -20,159 +18,173 @@ type Formatter struct { DisableLineBreak bool } -func (f Formatter) Format(ctx context.Context, level Level, tag string, message string, timestamp time.Time) string { - levelString := strings.ToUpper(FormatLevel(level)) - if !f.DisableColors { +var ( + levelLabels [LevelTrace + 1]string + coloredLevelLabels [LevelTrace + 1]string +) + +func init() { + for level := LevelPanic; level <= LevelTrace; level++ { + label := strings.ToUpper(FormatLevel(level)) + levelLabels[level] = label switch level { case LevelDebug, LevelTrace: - levelString = aurora.White(levelString).String() + coloredLevelLabels[level] = aurora.White(label).String() case LevelInfo: - levelString = aurora.Cyan(levelString).String() + coloredLevelLabels[level] = aurora.Cyan(label).String() case LevelNotice: - levelString = aurora.Green(levelString).String() + coloredLevelLabels[level] = aurora.Green(label).String() case LevelWarn: - levelString = aurora.Yellow(levelString).String() + coloredLevelLabels[level] = aurora.Yellow(label).String() case LevelError, LevelFatal, LevelPanic: - levelString = aurora.Red(levelString).String() + coloredLevelLabels[level] = aurora.Red(label).String() } } - if tag != "" { - message = tag + ": " + message - } +} + +func (f Formatter) Format(ctx context.Context, level Level, tag string, message string, timestamp time.Time) string { var id ID var hasId bool if ctx != nil { id, hasId = IDFromContext(ctx) } + var builder strings.Builder + builder.Grow(len(tag) + len(message) + 64) + f.writePrefix(&builder, level, timestamp) if hasId { - activeDuration := FormatDuration(time.Since(id.CreatedAt)) - if !f.DisableColors { - var color aurora.Color - color = aurora.Color(uint8(id.ID)) - color %= 215 - row := uint(color / 36) - column := uint(color % 36) - - var r, g, b float32 - r = float32(row * 51) - g = float32(column / 6 * 51) - b = float32((column % 6) * 51) - luma := 0.2126*r + 0.7152*g + 0.0722*b - if luma < 60 { - row = 5 - row - column = 35 - column - color = aurora.Color(row*36 + column) - } - color += 16 - color = color << 16 - color |= 1 << 14 - message = F.ToString("[", aurora.Colorize(id.ID, color).String(), " ", activeDuration, "] ", message) - } else { - message = F.ToString("[", id.ID, " ", activeDuration, "] ", message) - } + f.writeIdPrefix(&builder, id) } - switch { - case f.DisableTimestamp: - message = levelString + " " + message - case f.FullTimestamp: - message = timestamp.Format(f.TimestampFormat) + " " + levelString + " " + message - default: - message = levelString + "[" + xd(int(timestamp.Sub(f.BaseTime)/time.Second), 4) + "] " + message + if tag != "" { + builder.WriteString(tag) + builder.WriteString(": ") } if f.DisableLineBreak { - if message[len(message)-1] == '\n' { - message = message[:len(message)-1] - } + builder.WriteString(strings.TrimSuffix(message, "\n")) } else { - if message[len(message)-1] != '\n' { - message += "\n" + builder.WriteString(message) + if !strings.HasSuffix(message, "\n") { + builder.WriteByte('\n') } } - return message + return builder.String() } -func (f Formatter) FormatWithSimple(ctx context.Context, level Level, tag string, message string, timestamp time.Time) (string, string) { - levelString := strings.ToUpper(FormatLevel(level)) - if !f.DisableColors { - switch level { - case LevelDebug, LevelTrace: - levelString = aurora.White(levelString).String() - case LevelInfo: - levelString = aurora.Cyan(levelString).String() - case LevelNotice: - levelString = aurora.Green(levelString).String() - case LevelWarn: - levelString = aurora.Yellow(levelString).String() - case LevelError, LevelFatal, LevelPanic: - levelString = aurora.Red(levelString).String() - } - } - if tag != "" { - message = tag + ": " + message - } - messageSimple := message +func (f Formatter) FormatSimple(ctx context.Context, tag string, message string) string { var id ID var hasId bool if ctx != nil { id, hasId = IDFromContext(ctx) } + if !hasId && tag == "" { + return message + } + var builder strings.Builder + builder.Grow(len(tag) + len(message) + 32) if hasId { - activeDuration := FormatDuration(time.Since(id.CreatedAt)) - if !f.DisableColors { - var color aurora.Color - color = aurora.Color(uint8(id.ID)) - color %= 215 - row := uint(color / 36) - column := uint(color % 36) - - var r, g, b float32 - r = float32(row * 51) - g = float32(column / 6 * 51) - b = float32((column % 6) * 51) - luma := 0.2126*r + 0.7152*g + 0.0722*b - if luma < 60 { - row = 5 - row - column = 35 - column - color = aurora.Color(row*36 + column) - } - color += 16 - color = color << 16 - color |= 1 << 14 - message = F.ToString("[", aurora.Colorize(id.ID, color).String(), " ", activeDuration, "] ", message) - } else { - message = F.ToString("[", id.ID, " ", activeDuration, "] ", message) - } - messageSimple = F.ToString("[", id.ID, " ", activeDuration, "] ", messageSimple) + builder.WriteByte('[') + writeUint(&builder, uint64(id.ID)) + builder.WriteByte(' ') + writeDuration(&builder, time.Since(id.CreatedAt)) + builder.WriteString("] ") + } + if tag != "" { + builder.WriteString(tag) + builder.WriteString(": ") + } + builder.WriteString(message) + return builder.String() +} +func (f Formatter) writePrefix(builder *strings.Builder, level Level, timestamp time.Time) { + var levelString string + if int(level) >= len(levelLabels) { + levelString = "UNKNOWN" + } else if f.DisableColors { + levelString = levelLabels[level] + } else { + levelString = coloredLevelLabels[level] } switch { case f.DisableTimestamp: - message = levelString + " " + message + builder.WriteString(levelString) + builder.WriteByte(' ') case f.FullTimestamp: - message = timestamp.Format(f.TimestampFormat) + " " + levelString + " " + message + var timeBuffer [64]byte + builder.Write(timestamp.AppendFormat(timeBuffer[:0], f.TimestampFormat)) + builder.WriteByte(' ') + builder.WriteString(levelString) + builder.WriteByte(' ') default: - message = levelString + "[" + xd(int(timestamp.Sub(f.BaseTime)/time.Second), 4) + "] " + message + builder.WriteString(levelString) + builder.WriteByte('[') + seconds := strconv.AppendInt(make([]byte, 0, 20), int64(timestamp.Sub(f.BaseTime)/time.Second), 10) + for pad := 4 - len(seconds); pad > 0; pad-- { + builder.WriteByte('0') + } + builder.Write(seconds) + builder.WriteString("] ") } - if message[len(message)-1] != '\n' { - message += "\n" - } - return message, messageSimple } -func xd(value int, x int) string { - message := strconv.Itoa(value) - for len(message) < x { - message = "0" + message +func (f Formatter) writeIdPrefix(builder *strings.Builder, id ID) { + builder.WriteByte('[') + if f.DisableColors { + writeUint(builder, uint64(id.ID)) + } else { + builder.WriteString("\x1b[38;5;") + writeUint(builder, uint64(colorForID(id.ID))) + builder.WriteByte('m') + writeUint(builder, uint64(id.ID)) + builder.WriteString("\x1b[0m") + } + builder.WriteByte(' ') + writeDuration(builder, time.Since(id.CreatedAt)) + builder.WriteString("] ") +} + +func colorForID(value uint32) uint8 { + color := uint8(value) % 215 + row := uint(color / 36) + column := uint(color % 36) + r := float32(row * 51) + g := float32(column / 6 * 51) + b := float32((column % 6) * 51) + luma := 0.2126*r + 0.7152*g + 0.0722*b + if luma < 60 { + row = 5 - row + column = 35 - column + color = uint8(row*36 + column) + } + return color + 16 +} + +func writeUint(builder *strings.Builder, value uint64) { + builder.Write(strconv.AppendUint(make([]byte, 0, 20), value, 10)) +} + +func writeInt(builder *strings.Builder, value int64) { + builder.Write(strconv.AppendInt(make([]byte, 0, 20), value, 10)) +} + +func writeDuration(builder *strings.Builder, duration time.Duration) { + if duration < time.Second { + writeInt(builder, duration.Milliseconds()) + builder.WriteString("ms") + } else if duration < time.Minute { + writeInt(builder, int64(duration.Seconds())) + builder.WriteByte('.') + writeInt(builder, int64(duration.Seconds()*100)%100) + builder.WriteByte('s') + } else { + writeInt(builder, int64(duration.Minutes())) + builder.WriteByte('m') + writeInt(builder, int64(duration.Seconds())%60) + builder.WriteByte('s') } - return message } func FormatDuration(duration time.Duration) string { - if duration < time.Second { - return F.ToString(duration.Milliseconds(), "ms") - } else if duration < time.Minute { - return F.ToString(int64(duration.Seconds()), ".", int64(duration.Seconds()*100)%100, "s") - } else { - return F.ToString(int64(duration.Minutes()), "m", int64(duration.Seconds())%60, "s") - } + var builder strings.Builder + writeDuration(&builder, duration) + return builder.String() } diff --git a/log/observable.go b/log/observable.go index e5167cff..f2164098 100644 --- a/log/observable.go +++ b/log/observable.go @@ -24,6 +24,7 @@ type defaultFactory struct { file *os.File filePath string platformWriters atomic.Pointer[[]PlatformWriter] + needConsole bool needObservable bool level Level subscriber *observable.Subscriber[Entry] @@ -58,6 +59,7 @@ func NewDefaultFactory( }, writer: writer, filePath: filePath, + needConsole: writer != io.Discard || filePath != "", needObservable: needObservable, level: LevelTrace, subscriber: observable.NewSubscriber[Entry](128), @@ -81,6 +83,7 @@ func (f *defaultFactory) Start() error { f.writer = logFile f.file = logFile } + f.needConsole = f.writer != io.Discard } if f.needObservable { f.observer = observable.NewObserver[Entry](f.subscriber, 64) @@ -144,19 +147,7 @@ func (f *defaultFactory) UnSubscribe(sub observable.Subscription[Entry]) { } func (f *defaultFactory) output(ctx context.Context, level Level, tag string, message string, timestamp time.Time) { - if f.needObservable { - formatted, formattedSimple := f.formatter.FormatWithSimple(ctx, level, tag, message, timestamp) - if level <= f.level { - if level == LevelPanic { - panic(formatted) - } - f.writer.Write([]byte(formatted)) - if level == LevelFatal { - os.Exit(1) - } - } - f.subscriber.Emit(Entry{level, formattedSimple}) - } else if level <= f.level { + if level <= f.level && (f.needConsole || level == LevelPanic || level == LevelFatal) { formatted := f.formatter.Format(ctx, level, tag, message, timestamp) if level == LevelPanic { panic(formatted) @@ -166,6 +157,9 @@ func (f *defaultFactory) output(ctx context.Context, level Level, tag string, me os.Exit(1) } } + if f.needObservable { + f.subscriber.Emit(Entry{level, f.formatter.FormatSimple(ctx, tag, message)}) + } platformWriters := f.loadPlatformWriters() if len(platformWriters) > 0 { platformMessage := f.platformFormatter.Format(ctx, level, tag, message, timestamp) diff --git a/option/experimental.go b/option/experimental.go index bb2e1012..c9633927 100644 --- a/option/experimental.go +++ b/option/experimental.go @@ -30,7 +30,6 @@ type ClashAPIOptions struct { ExternalUIDownloadDetour string `json:"external_ui_download_detour,omitempty" reference:"outbound"` Secret string `json:"secret,omitempty"` DefaultMode string `json:"default_mode,omitempty"` - ModeList []string `json:"-"` AccessControlAllowOrigin badoption.Listable[string] `json:"access_control_allow_origin,omitempty"` AccessControlAllowPrivateNetwork bool `json:"access_control_allow_private_network,omitempty"` diff --git a/option/http.go b/option/http.go index 4c6da9de..c51b2643 100644 --- a/option/http.go +++ b/option/http.go @@ -12,11 +12,11 @@ import ( ) type HTTP2Options struct { - IdleTimeout badoption.Duration `json:"idle_timeout,omitempty"` - KeepAlivePeriod badoption.Duration `json:"keep_alive_period,omitempty"` - StreamReceiveWindow byteformats.MemoryBytes `json:"stream_receive_window,omitempty"` - ConnectionReceiveWindow byteformats.MemoryBytes `json:"connection_receive_window,omitempty"` - MaxConcurrentStreams int `json:"max_concurrent_streams,omitempty"` + IdleTimeout badoption.Duration `json:"idle_timeout,omitempty"` + KeepAlivePeriod badoption.Duration `json:"keep_alive_period,omitempty"` + StreamReceiveWindow *byteformats.MemoryBytes `json:"stream_receive_window,omitempty"` + ConnectionReceiveWindow *byteformats.MemoryBytes `json:"connection_receive_window,omitempty"` + MaxConcurrentStreams int `json:"max_concurrent_streams,omitempty"` } type QUICOptions struct { diff --git a/option/openvpn.go b/option/openvpn.go index 7a12a4f0..d62aaf98 100644 --- a/option/openvpn.go +++ b/option/openvpn.go @@ -25,8 +25,8 @@ type OpenVPNClientEndpointOptions struct { Servers []OpenVPNRemoteOptions `json:"servers,omitempty"` RemoteRandom bool `json:"remote_random,omitempty"` Address badoption.Listable[netip.Prefix] `json:"address,omitempty"` - PeerAddress badoption.Addr `json:"peer_address,omitempty"` - PeerAddressIPv6 badoption.Addr `json:"peer_address_ipv6,omitempty"` + PeerAddress *badoption.Addr `json:"peer_address,omitempty"` + PeerAddressIPv6 *badoption.Addr `json:"peer_address_ipv6,omitempty"` Topology string `json:"topology,omitempty" enum:"net30,p2p,subnet"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` @@ -81,8 +81,8 @@ type OpenVPNServerEndpointOptions struct { RemotePort uint16 `json:"remote_port,omitempty"` MaxClients int `json:"max_clients,omitempty"` Address badoption.Listable[netip.Prefix] `json:"address"` - PeerAddress badoption.Addr `json:"peer_address,omitempty"` - PeerAddressIPv6 badoption.Addr `json:"peer_address_ipv6,omitempty"` + PeerAddress *badoption.Addr `json:"peer_address,omitempty"` + PeerAddressIPv6 *badoption.Addr `json:"peer_address_ipv6,omitempty"` Topology string `json:"topology,omitempty" enum:"net30,p2p,subnet"` DuplicateCN bool `json:"duplicate_cn,omitempty"` Users []auth.User `json:"users,omitempty"` diff --git a/protocol/direct/outbound.go b/protocol/direct/outbound.go index 1ca35af0..020b3d62 100644 --- a/protocol/direct/outbound.go +++ b/protocol/direct/outbound.go @@ -99,7 +99,11 @@ func (h *Outbound) Start(stage adapter.StartStage) error { } func (h *Outbound) fetchMyAddresses() { - myInterfaceNames := h.network.InterfaceMonitor().MyInterfaces() + interfaceMonitor := h.network.InterfaceMonitor() + if interfaceMonitor == nil { + return + } + myInterfaceNames := interfaceMonitor.MyInterfaces() if len(myInterfaceNames) == 0 { return } diff --git a/protocol/group/selector.go b/protocol/group/selector.go index 0a344103..584e4f3f 100644 --- a/protocol/group/selector.go +++ b/protocol/group/selector.go @@ -190,7 +190,7 @@ func (s *Selector) NewConnection(ctx context.Context, conn net.Conn, metadata ad if outboundHandler, isHandler := selected.(adapter.ConnectionHandler); isHandler { outboundHandler.NewConnection(ctx, conn, metadata, onClose) } else { - s.connection.NewConnection(ctx, selected, conn, metadata, onClose) + s.connection.NewConnection(ctx, s, conn, metadata, onClose) } } @@ -201,7 +201,7 @@ func (s *Selector) NewPacketConnection(ctx context.Context, conn N.PacketConn, m if outboundHandler, isHandler := selected.(adapter.PacketConnectionHandler); isHandler { outboundHandler.NewPacketConnection(ctx, conn, metadata, onClose) } else { - s.connection.NewPacketConnection(ctx, selected, conn, metadata, onClose) + s.connection.NewPacketConnection(ctx, s, conn, metadata, onClose) } } diff --git a/protocol/mixed/inbound.go b/protocol/mixed/inbound.go index 5f13f972..3d1b61b7 100644 --- a/protocol/mixed/inbound.go +++ b/protocol/mixed/inbound.go @@ -17,6 +17,7 @@ import ( "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/auth" 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/protocol/http" "github.com/sagernet/sing/protocol/socks" @@ -152,6 +153,7 @@ func (h *Inbound) newUserConnection(ctx context.Context, conn net.Conn, metadata func (h *Inbound) streamUserPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { metadata.Inbound = h.Tag() metadata.InboundType = h.Type() + metadata.OriginDestination = M.SocksaddrFromNet(conn.LocalAddr()).Unwrap() user, loaded := auth.UserFromContext[string](ctx) if !loaded { if !metadata.Destination.IsValid() { diff --git a/protocol/openvpn/client.go b/protocol/openvpn/client.go index 42715ef6..03f8e07a 100644 --- a/protocol/openvpn/client.go +++ b/protocol/openvpn/client.go @@ -405,11 +405,11 @@ func buildClientDataChannelOptions(options option.OpenVPNClientEndpointOptions) } func buildClientTunnelOptions(options option.OpenVPNClientEndpointOptions, requirePeerAddress bool) (ovpn.ClientTunnelOptions, error) { - vpnGateway := netip.Addr(options.PeerAddress) + vpnGateway := options.PeerAddress.Build(netip.Addr{}) if vpnGateway.IsValid() && !vpnGateway.Is4() { return ovpn.ClientTunnelOptions{}, E.New("`peer_address` must be an IPv4 address") } - vpnGatewayIPv6 := netip.Addr(options.PeerAddressIPv6) + vpnGatewayIPv6 := options.PeerAddressIPv6.Build(netip.Addr{}) if vpnGatewayIPv6.IsValid() && !vpnGatewayIPv6.Is6() { return ovpn.ClientTunnelOptions{}, E.New("`peer_address_ipv6` must be an IPv6 address") } diff --git a/protocol/openvpn/server.go b/protocol/openvpn/server.go index 9c4ee269..cd8ccc66 100644 --- a/protocol/openvpn/server.go +++ b/protocol/openvpn/server.go @@ -297,7 +297,7 @@ func buildServerOptions(options option.OpenVPNServerEndpointOptions) (ovpn.Serve if options.TLS == nil { return ovpn.ServerOptions{}, E.New("missing `tls` options") } - if len(options.StaticKey) > 0 || options.StaticKeyPath != "" || options.KeyDirection != "" || options.Cipher != "" || options.Remote != "" || options.RemotePort != 0 || netip.Addr(options.PeerAddress).IsValid() || netip.Addr(options.PeerAddressIPv6).IsValid() { + if len(options.StaticKey) > 0 || options.StaticKeyPath != "" || options.KeyDirection != "" || options.Cipher != "" || options.Remote != "" || options.RemotePort != 0 || options.PeerAddress.Build(netip.Addr{}).IsValid() || options.PeerAddressIPv6.Build(netip.Addr{}).IsValid() { return ovpn.ServerOptions{}, E.New("static-key server options require `mode: static_key`") } tlsOptions, keyDirection, err := buildServerTLSOptions(*options.TLS) @@ -367,11 +367,11 @@ func buildStaticKeyServerOptions(options option.OpenVPNServerEndpointOptions, pr if err != nil { return ovpn.ServerOptions{}, err } - vpnGateway := netip.Addr(options.PeerAddress) + vpnGateway := options.PeerAddress.Build(netip.Addr{}) if vpnGateway.IsValid() && !vpnGateway.Is4() { return ovpn.ServerOptions{}, E.New("`peer_address` must be an IPv4 address") } - vpnGatewayIPv6 := netip.Addr(options.PeerAddressIPv6) + vpnGatewayIPv6 := options.PeerAddressIPv6.Build(netip.Addr{}) if vpnGatewayIPv6.IsValid() && !vpnGatewayIPv6.Is6() { return ovpn.ServerOptions{}, E.New("`peer_address_ipv6` must be an IPv6 address") } diff --git a/protocol/socks/inbound.go b/protocol/socks/inbound.go index 2c8d248c..04e02a48 100644 --- a/protocol/socks/inbound.go +++ b/protocol/socks/inbound.go @@ -16,6 +16,7 @@ import ( "github.com/sagernet/sing/common/auth" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/protocol/socks" ) @@ -103,6 +104,7 @@ func (h *Inbound) newUserConnection(ctx context.Context, conn net.Conn, metadata func (h *Inbound) streamUserPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { metadata.Inbound = h.Tag() metadata.InboundType = h.Type() + metadata.OriginDestination = M.SocksaddrFromNet(conn.LocalAddr()).Unwrap() user, loaded := auth.UserFromContext[string](ctx) if !loaded { if !metadata.Destination.IsValid() { diff --git a/protocol/tailscale/dns_transport.go b/protocol/tailscale/dns_transport.go index 38c9a1ee..b27a7492 100644 --- a/protocol/tailscale/dns_transport.go +++ b/protocol/tailscale/dns_transport.go @@ -129,7 +129,7 @@ func (t *DNSTransport) updateDNSServers(routeConfig *router.Config, dnsConfig *n } myResolvers = append(myResolvers, myResolver) } - routes[domain.WithTrailingDot()] = myResolvers + routes[mDNS.CanonicalName(domain.WithTrailingDot())] = myResolvers } hosts := make(map[string][]netip.Addr) for domain, addresses := range dnsConfig.Hosts { @@ -275,14 +275,25 @@ func (t *DNSTransport) PreferredDomain(domain string) bool { if t.acceptSearchDomain && len(searchDomains) > 0 && mDNS.CountLabel(domain) == 1 { return true } + canonicalDomain := mDNS.CanonicalName(domain) for suffix := range routes { - if mDNS.IsSubDomain(suffix, domain) { + if matchDomainSuffix(canonicalDomain, suffix) { return true } } return false } +func matchDomainSuffix(domain string, suffix string) bool { + if suffix == "." || suffix == "" { + return true + } + if !strings.HasSuffix(domain, suffix) { + return false + } + return len(domain) == len(suffix) || domain[len(domain)-len(suffix)-1] == '.' +} + func (t *DNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { done := make(chan struct{}) var ( @@ -375,8 +386,9 @@ func (t *DNSTransport) exchangeOnce(ctx context.Context, message *mDNS.Msg, allo } return } + canonicalName := mDNS.CanonicalName(question.Name) for domainSuffix, transports := range routes { - if mDNS.IsSubDomain(domainSuffix, question.Name) { + if matchDomainSuffix(canonicalName, domainSuffix) { if len(transports) == 0 { callback(&mDNS.Msg{ MsgHdr: mDNS.MsgHdr{ diff --git a/protocol/tailscale/endpoint.go b/protocol/tailscale/endpoint.go index 29a1c8bc..35eeb295 100644 --- a/protocol/tailscale/endpoint.go +++ b/protocol/tailscale/endpoint.go @@ -953,7 +953,7 @@ func (t *Endpoint) PreferredDomain(metadata *adapter.InboundContext, domain stri } } for _, suffix := range t.routeSuffixes.Load() { - if mDNS.IsSubDomain(suffix, domain) { + if matchDomainSuffix(domain, suffix) { return true } } @@ -1000,7 +1000,7 @@ func (t *Endpoint) onReconfig(cfg *wgcfg.Config, routerCfg *router.Config, dnsCf } routeSuffixes := make([]string, 0, len(dnsCfg.Routes)) for fqdn := range dnsCfg.Routes { - routeSuffixes = append(routeSuffixes, fqdn.WithoutTrailingDot()) + routeSuffixes = append(routeSuffixes, strings.ToLower(fqdn.WithoutTrailingDot())) } t.routeDomains.Store(routeDomains) t.routeSuffixes.Store(routeSuffixes) diff --git a/route/network.go b/route/network.go index fa4f0f57..f2cc7fc2 100644 --- a/route/network.go +++ b/route/network.go @@ -291,7 +291,7 @@ func (r *NetworkManager) InterfaceFinder() control.InterfaceFinder { } func (r *NetworkManager) UpdateInterfaces() error { - defer r.updateNetworkEnvironment() + defer r.postUpdateNetworkEnvironment() if r.platformInterface == nil || !r.platformInterface.UsePlatformNetworkInterfaces() { return r.interfaceFinder.Update() } else { diff --git a/route/network_environment_darwin.go b/route/network_environment_darwin.go index 92633878..ad84d3a2 100644 --- a/route/network_environment_darwin.go +++ b/route/network_environment_darwin.go @@ -10,7 +10,7 @@ import ( ) func systemGateways(interfaceIndex int) []netip.Addr { - rib, err := route.FetchRIB(unix.AF_UNSPEC, route.RIBTypeRoute, 0) + rib, err := route.FetchRIB(unix.AF_UNSPEC, route.RIBType(unix.NET_RT_FLAGS), unix.RTF_GATEWAY) if err != nil { return nil } diff --git a/route/route.go b/route/route.go index ec981670..14873089 100644 --- a/route/route.go +++ b/route/route.go @@ -404,9 +404,20 @@ func (r *Router) PreMatch(metadata adapter.InboundContext, firstPacket []byte) a } return adapter.PreMatchResult{Action: adapter.PreMatchBypass} } - return r.preMatchFlow(ctx, &metadata, packetDestination, currentRule, action.Outbound) + if metadata.Destination.IsDomain() || metadata.Destination != packetDestination { + return r.preMatchFlow(ctx, &metadata, packetDestination, currentRule, action.Outbound) + } + result := r.preMatchFlow(ctx, &metadata, packetDestination, currentRule, action.Outbound) + if result.Action != adapter.PreMatchFlow { + return adapter.PreMatchResult{Action: adapter.PreMatchBypass} + } + result.Action = adapter.PreMatchBypass + return result case *R.RuleActionReject: rejectErr := action.Error(r.ctx) + if rejectErr == nil && metadata.Network == N.NetworkICMP { + return continueResult + } if errors.Is(rejectErr, R.ErrDrop) { return adapter.PreMatchResult{Action: adapter.PreMatchDrop} } @@ -524,9 +535,9 @@ func (r *Router) preMatchFlow(ctx context.Context, metadata *adapter.InboundCont } else if metadata.Destination != packetDestination { result.Destination = metadata.Destination.AddrPort() } - r.logger.InfoContext(ctx, "pre-match: forward ", metadata.Network, " connection from ", metadata.Source.AddrString(), " to ", metadata.Destination.AddrString(), " via outbound/", outbound.Type(), "[", outbound.Tag(), "]") metadataCopy := *metadata result.NewTracker = func() tun.FlowTracker { + r.logger.InfoContext(ctx, "pre-match: forward ", metadataCopy.Network, " connection from ", metadataCopy.Source.AddrString(), " to ", metadataCopy.Destination.AddrString(), " via outbound/", outbound.Type(), "[", outbound.Tag(), "]") flowTrackers := make([]tun.FlowTracker, 0, len(r.trackers)+1) flowTrackers = append(flowTrackers, newFlowLogger(ctx, r.logger, metadataCopy, outbound)) for _, tracker := range r.trackers { diff --git a/route/rule/match_state.go b/route/rule/match_state.go index f7f937f8..e6b4fd84 100644 --- a/route/rule/match_state.go +++ b/route/rule/match_state.go @@ -1,5 +1,7 @@ package rule +import "github.com/sagernet/sing-box/adapter" + type ruleMatchState uint8 const ( @@ -24,3 +26,23 @@ func (g ruleGroupMatch) mergeWith(other ruleGroupMatch) ruleGroupMatch { satisfied: g.satisfied | other.satisfied, } } + +type ruleMatchSnapshot struct { + ipCidrMatchSource bool + ipCidrAcceptEmpty bool + deferredIPCIDRMatchGroups uint8 +} + +func snapshotRuleMatch(metadata *adapter.InboundContext) ruleMatchSnapshot { + return ruleMatchSnapshot{ + ipCidrMatchSource: metadata.IPCIDRMatchSource, + ipCidrAcceptEmpty: metadata.IPCIDRAcceptEmpty, + deferredIPCIDRMatchGroups: metadata.DeferredIPCIDRMatchGroups, + } +} + +func (s ruleMatchSnapshot) restore(metadata *adapter.InboundContext) { + metadata.IPCIDRMatchSource = s.ipCidrMatchSource + metadata.IPCIDRAcceptEmpty = s.ipCidrAcceptEmpty + metadata.DeferredIPCIDRMatchGroups = s.deferredIPCIDRMatchGroups +} diff --git a/route/rule/rule_abstract.go b/route/rule/rule_abstract.go index f21df788..cb7d5060 100644 --- a/route/rule/rule_abstract.go +++ b/route/rule/rule_abstract.go @@ -206,32 +206,32 @@ func (r *abstractLogicalRule) Match(metadata *adapter.InboundContext) bool { matched bool deferredGroups uint8 ) + snapshot := snapshotRuleMatch(metadata) if r.mode == C.LogicalTypeAnd { matched = true for _, rule := range r.rules { - nestedMetadata := *metadata - nestedMetadata.ResetRuleCache() - if !rule.Match(&nestedMetadata) { + metadata.ResetRuleCache() + if !rule.Match(metadata) { matched = false deferredGroups = 0 break } - deferredGroups |= nestedMetadata.DeferredIPCIDRMatchGroups + deferredGroups |= metadata.DeferredIPCIDRMatchGroups } } else { for _, rule := range r.rules { - nestedMetadata := *metadata - nestedMetadata.ResetRuleCache() - if rule.Match(&nestedMetadata) { + metadata.ResetRuleCache() + if rule.Match(metadata) { matched = true - if nestedMetadata.DeferredIPCIDRMatchGroups == 0 { + if metadata.DeferredIPCIDRMatchGroups == 0 { deferredGroups = 0 break } - deferredGroups |= nestedMetadata.DeferredIPCIDRMatchGroups + deferredGroups |= metadata.DeferredIPCIDRMatchGroups } } } + snapshot.restore(metadata) if matched { metadata.DeferredIPCIDRMatchGroups |= deferredGroups } diff --git a/route/rule/rule_default_interface_address.go b/route/rule/rule_default_interface_address.go index 2d7fdebe..611cb0dc 100644 --- a/route/rule/rule_default_interface_address.go +++ b/route/rule/rule_default_interface_address.go @@ -29,6 +29,9 @@ func NewDefaultInterfaceAddressItem(networkManager adapter.NetworkManager, inter } func (r *DefaultInterfaceAddressItem) Match(metadata *adapter.InboundContext) bool { + if r.interfaceMonitor == nil { + return false + } defaultInterface := r.interfaceMonitor.DefaultInterface() if defaultInterface == nil { return false diff --git a/route/rule/rule_item_clash_mode.go b/route/rule/rule_item_clash_mode.go index fe2347a0..799ae06d 100644 --- a/route/rule/rule_item_clash_mode.go +++ b/route/rule/rule_item_clash_mode.go @@ -5,15 +5,16 @@ import ( "strings" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/experimental/clashmode" "github.com/sagernet/sing/service" ) var _ RuleItem = (*ClashModeItem)(nil) type ClashModeItem struct { - ctx context.Context - clashServer adapter.ClashServer - mode string + ctx context.Context + clashMode *clashmode.Manager + mode string } func NewClashModeItem(ctx context.Context, mode string) *ClashModeItem { @@ -24,15 +25,15 @@ func NewClashModeItem(ctx context.Context, mode string) *ClashModeItem { } func (r *ClashModeItem) Start() error { - r.clashServer = service.FromContext[adapter.ClashServer](r.ctx) + r.clashMode = service.PtrFromContext[clashmode.Manager](r.ctx) return nil } func (r *ClashModeItem) Match(metadata *adapter.InboundContext) bool { - if r.clashServer == nil { + if r.clashMode == nil { return false } - return strings.EqualFold(r.clashServer.Mode(), r.mode) + return strings.EqualFold(r.clashMode.Mode(), r.mode) } func (r *ClashModeItem) String() string { diff --git a/route/rule/rule_item_rule_set.go b/route/rule/rule_item_rule_set.go index eb7c74e1..256183e2 100644 --- a/route/rule/rule_item_rule_set.go +++ b/route/rule/rule_item_rule_set.go @@ -52,12 +52,15 @@ func (r *RuleSetItem) Close() error { } func (r *RuleSetItem) Match(metadata *adapter.InboundContext) bool { + snapshot := snapshotRuleMatch(metadata) for _, ruleSet := range r.setList { - nestedMetadata := r.nestedMetadata(metadata) - if ruleSet.Match(&nestedMetadata) { + r.prepareNestedMatch(metadata) + if ruleSet.Match(metadata) { + snapshot.restore(metadata) return true } } + snapshot.restore(metadata) return false } @@ -67,17 +70,19 @@ func (r *RuleSetItem) matchWithOuterGroups(metadata *adapter.InboundContext, out matched bool deferredGroups uint8 ) + snapshot := snapshotRuleMatch(metadata) for _, ruleSet := range r.setList { - nestedMetadata := r.nestedMetadata(metadata) + r.prepareNestedMatch(metadata) if provider, isProvider := ruleSet.(mergeableRuleProvider); isProvider { branch := provider.mergeableRule() if branch != nil { - branchGroups, branchMatched := branch.evaluateForMerge(&nestedMetadata) + branchGroups, branchMatched := branch.evaluateForMerge(metadata) if branchMatched { merged := outerGroups.mergeWith(branchGroups) if merged.done() { - branchDeferredGroups := nestedMetadata.DeferredIPCIDRMatchGroups &^ uint8(merged.satisfied) + branchDeferredGroups := metadata.DeferredIPCIDRMatchGroups &^ uint8(merged.satisfied) if branchDeferredGroups == 0 { + snapshot.restore(metadata) metadata.DeferredIPCIDRMatchGroups &^= uint8(merged.satisfied) return true } @@ -88,26 +93,26 @@ func (r *RuleSetItem) matchWithOuterGroups(metadata *adapter.InboundContext, out continue } } - if outerDone && ruleSet.Match(&nestedMetadata) { - if nestedMetadata.DeferredIPCIDRMatchGroups == 0 { + if outerDone && ruleSet.Match(metadata) { + if metadata.DeferredIPCIDRMatchGroups == 0 { + snapshot.restore(metadata) return true } matched = true - deferredGroups |= nestedMetadata.DeferredIPCIDRMatchGroups + deferredGroups |= metadata.DeferredIPCIDRMatchGroups } } + snapshot.restore(metadata) if matched { metadata.DeferredIPCIDRMatchGroups |= deferredGroups } return matched } -func (r *RuleSetItem) nestedMetadata(metadata *adapter.InboundContext) adapter.InboundContext { - nestedMetadata := *metadata - nestedMetadata.ResetRuleMatchCache() - nestedMetadata.IPCIDRMatchSource = r.ipCidrMatchSource - nestedMetadata.IPCIDRAcceptEmpty = r.ipCidrAcceptEmpty - return nestedMetadata +func (r *RuleSetItem) prepareNestedMatch(metadata *adapter.InboundContext) { + metadata.ResetRuleMatchCache() + metadata.IPCIDRMatchSource = r.ipCidrMatchSource + metadata.IPCIDRAcceptEmpty = r.ipCidrAcceptEmpty } type mergeableRuleProvider interface { @@ -130,17 +135,19 @@ func matchAnyHeadlessRule(rules []adapter.HeadlessRule, metadata *adapter.Inboun matched bool deferredGroups uint8 ) + snapshot := snapshotRuleMatch(metadata) for _, rule := range rules { - nestedMetadata := *metadata - nestedMetadata.ResetRuleMatchCache() - if rule.Match(&nestedMetadata) { - if nestedMetadata.DeferredIPCIDRMatchGroups == 0 { + metadata.ResetRuleMatchCache() + if rule.Match(metadata) { + if metadata.DeferredIPCIDRMatchGroups == 0 { + snapshot.restore(metadata) return true } matched = true - deferredGroups |= nestedMetadata.DeferredIPCIDRMatchGroups + deferredGroups |= metadata.DeferredIPCIDRMatchGroups } } + snapshot.restore(metadata) if matched { metadata.DeferredIPCIDRMatchGroups |= deferredGroups } diff --git a/route/rule/rule_network_interface_address.go b/route/rule/rule_network_interface_address.go index e331d622..d20e6781 100644 --- a/route/rule/rule_network_interface_address.go +++ b/route/rule/rule_network_interface_address.go @@ -40,7 +40,11 @@ func NewNetworkInterfaceAddressItem(networkManager adapter.NetworkManager, inter func (r *NetworkInterfaceAddressItem) Match(metadata *adapter.InboundContext) bool { interfaces := r.networkManager.NetworkInterfaces() - myInterfaces := r.networkManager.InterfaceMonitor().MyInterfaces() + var myInterfaces []string + interfaceMonitor := r.networkManager.InterfaceMonitor() + if interfaceMonitor != nil { + myInterfaces = interfaceMonitor.MyInterfaces() + } match: for ifType, addresses := range r.interfaceAddresses { for _, networkInterface := range interfaces { diff --git a/service/oomkiller/lock_unix.go b/service/oomkiller/lock_unix.go new file mode 100644 index 00000000..677b20ff --- /dev/null +++ b/service/oomkiller/lock_unix.go @@ -0,0 +1,26 @@ +//go:build !windows + +package oomkiller + +import ( + "os" + "syscall" +) + +func lockDraft(path string) (*os.File, error) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o666) + if err != nil { + return nil, err + } + err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err != nil { + file.Close() + return nil, err + } + return file, nil +} + +func unlockDraft(file *os.File) { + syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + file.Close() +} diff --git a/service/oomkiller/lock_windows.go b/service/oomkiller/lock_windows.go new file mode 100644 index 00000000..7a2767fe --- /dev/null +++ b/service/oomkiller/lock_windows.go @@ -0,0 +1,25 @@ +package oomkiller + +import ( + "os" + + "golang.org/x/sys/windows" +) + +func lockDraft(path string) (*os.File, error) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o666) + if err != nil { + return nil, err + } + err = windows.LockFileEx(windows.Handle(file.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, &windows.Overlapped{}) + if err != nil { + file.Close() + return nil, err + } + return file, nil +} + +func unlockDraft(file *os.File) { + windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &windows.Overlapped{}) + file.Close() +} diff --git a/service/oomkiller/policy.go b/service/oomkiller/policy.go index aa744301..31c4b790 100644 --- a/service/oomkiller/policy.go +++ b/service/oomkiller/policy.go @@ -10,7 +10,10 @@ import ( "github.com/sagernet/sing/service" ) -const DefaultAppleNetworkExtensionMemoryLimit = 50 * 1024 * 1024 +const ( + DefaultAppleNetworkExtensionMemoryLimit = 50 * 1024 * 1024 + DefaultAppleNetworkExtensionGCPercent = 50 +) type policyMode uint8 @@ -25,6 +28,19 @@ func (m policyMode) hasTimerMode() bool { return m != policyModeNone } +func (m policyMode) String() string { + switch m { + case policyModeMemoryLimit: + return "memory_limit" + case policyModeAvailable: + return "available" + case policyModeNetworkExtension: + return "network_extension" + default: + return "none" + } +} + func resolvePolicyMode(ctx context.Context, options option.OOMKillerServiceOptions) (uint64, policyMode) { platformInterface := service.FromContext[adapter.PlatformInterface](ctx) if C.IsIos && platformInterface != nil && platformInterface.UnderNetworkExtension() { diff --git a/service/oomkiller/promote.go b/service/oomkiller/promote.go new file mode 100644 index 00000000..6069624a --- /dev/null +++ b/service/oomkiller/promote.go @@ -0,0 +1,141 @@ +package oomkiller + +import ( + "bufio" + "encoding/json" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + E "github.com/sagernet/sing/common/exceptions" +) + +func PromoteDraft(basePath string, accept func(metadataContent []byte) bool) { + draftPath := filepath.Join(basePath, DraftDirectoryName) + info, err := os.Stat(draftPath) + if err != nil || !info.IsDir() { + return + } + lockPath := filepath.Join(draftPath, lockFileName) + lock, err := lockDraft(lockPath) + if err != nil { + return + } + os.Remove(lockPath) + unlockDraft(lock) + if !draftNotable(draftPath) { + os.RemoveAll(draftPath) + return + } + if accept != nil { + metadataContent, readErr := os.ReadFile(filepath.Join(draftPath, metadataFileName)) + if readErr != nil || !accept(metadataContent) { + os.RemoveAll(draftPath) + return + } + } + promoteDirectory(draftPath, filepath.Join(basePath, ReportsDirectoryName)) +} + +func draftNotable(draftPath string) bool { + file, err := os.Open(filepath.Join(draftPath, eventsFileName)) + if err != nil { + return false + } + defer file.Close() + scanner := bufio.NewScanner(file) + scanner.Buffer(nil, 1<<20) + for scanner.Scan() { + var event struct { + Type string `json:"t"` + } + err = json.Unmarshal(scanner.Bytes(), &event) + if err != nil { + continue + } + switch event.Type { + case eventTypePressure, eventTypeReset, eventTypeSnapshot: + return true + } + } + return false +} + +func promoteDirectory(draftPath string, reportsPath string) { + info, err := os.Stat(draftPath) + if err != nil || !info.IsDir() { + return + } + err = os.MkdirAll(reportsPath, 0o777) + if err != nil { + return + } + destPath, err := nextAvailableReportPath(reportsPath, info.ModTime().UTC()) + if err != nil { + os.RemoveAll(draftPath) + return + } + err = os.Rename(draftPath, destPath) + if err != nil { + os.RemoveAll(draftPath) + } +} + +func nextAvailableReportPath(reportsDir string, timestamp time.Time) (string, error) { + destName := timestamp.Format("2006-01-02T15-04-05") + destPath := filepath.Join(reportsDir, destName) + _, err := os.Stat(destPath) + if os.IsNotExist(err) { + return destPath, nil + } + for i := 1; i <= 1000; i++ { + suffixedPath := filepath.Join(reportsDir, destName+"-"+strconv.Itoa(i)) + _, err = os.Stat(suffixedPath) + if os.IsNotExist(err) { + return suffixedPath, nil + } + } + return "", E.New("no available report path for ", destName) +} + +func copyDirectory(sourcePath string, destPath string) error { + entries, err := os.ReadDir(sourcePath) + if err != nil { + return err + } + err = os.MkdirAll(destPath, 0o777) + if err != nil { + return err + } + for _, entry := range entries { + if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + err = copyFile(filepath.Join(sourcePath, entry.Name()), filepath.Join(destPath, entry.Name())) + if err != nil { + return err + } + } + return nil +} + +func copyFile(sourcePath string, destPath string) error { + source, err := os.Open(sourcePath) + if err != nil { + return err + } + defer source.Close() + dest, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o666) + if err != nil { + return err + } + _, err = io.Copy(dest, source) + if err != nil { + dest.Close() + return err + } + return dest.Close() +} diff --git a/service/oomkiller/recorder.go b/service/oomkiller/recorder.go new file mode 100644 index 00000000..c8805ef8 --- /dev/null +++ b/service/oomkiller/recorder.go @@ -0,0 +1,559 @@ +package oomkiller + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "runtime/metrics" + "strconv" + "sync" + "time" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/common/memory" +) + +const ( + DraftDirectoryName = "oom_draft" + ReportsDirectoryName = "oom_reports" + + timelineFileName = "timeline.jsonl" + eventsFileName = "events.jsonl" + metadataFileName = "metadata.json" + logFileName = "go.log" + lockFileName = ".lock" + + normalSampleInterval = time.Minute + pressureSampleInterval = time.Second + denseSampleWindow = 2 * time.Minute + + pressureSnapshotMinInterval = time.Hour + resetSnapshotMinInterval = 10 * time.Minute + maxAutomaticSnapshots = 8 +) + +const ( + SnapshotReasonPressure = "pressure" + SnapshotReasonReset = "reset" + SnapshotReasonManual = "manual" + + resetReasonThreshold = "threshold" + resetReasonRate = "rate" + + eventTypeStart = "start" + eventTypeStop = "stop" + eventTypePressure = "pressure" + eventTypeState = "state" + eventTypeReset = "reset" + eventTypeSnapshot = "snapshot" + eventTypeReport = "report" +) + +type RecorderOptions struct { + BasePath string + Logger logger.Logger + AcceptDraft func(metadataContent []byte) bool + MetadataCallback func(status ReportStatus) any + OwnerCallback func(path string) + LogCallback func() []byte + SnapshotCallback func(directory string, prefix string) +} + +type ReportStatus struct { + StartedAt time.Time + RecordedAt time.Time + EndedAt time.Time + MemoryLimit uint64 + PeakMemory uint64 + MinAvailable uint64 + AvailableKnown bool + Snapshots int +} + +type Recorder struct { + basePath string + draftPath string + logger logger.Logger + acceptDraft func(metadataContent []byte) bool + metadataCallback func(status ReportStatus) any + ownerCallback func(path string) + logCallback func() []byte + snapshotCallback func(directory string, prefix string) + + access sync.Mutex + started bool + closed bool + draftCreated bool + draftLock *os.File + notable bool + status ReportStatus + lastRowAt time.Time + lastRowState pressureState + hasRow bool + denseUntil time.Time + previousGCCycles uint64 + metricsSamples []metrics.Sample + + snapshotAccess sync.Mutex + snapshotSequence int + automaticSnapshots int + lastSnapshotAt map[string]time.Time +} + +type timelineRow struct { + At string `json:"at"` + State string `json:"state"` + MemoryBytes uint64 `json:"memoryBytes"` + AvailableBytes uint64 `json:"availableBytes,omitempty"` + GoMemoryBytes uint64 `json:"goMemoryBytes,omitempty"` + GoHeapLiveBytes uint64 `json:"goHeapLiveBytes,omitempty"` + GoStackBytes uint64 `json:"goStackBytes,omitempty"` + Goroutines uint64 `json:"goroutines,omitempty"` + GCCycles uint64 `json:"gcCycles,omitempty"` + Connections int `json:"connections,omitempty"` +} + +type eventRecord struct { + Type string `json:"t"` + At string `json:"at"` + Policy string `json:"policy,omitempty"` + State string `json:"state,omitempty"` + Reason string `json:"reason,omitempty"` + MemoryBytes uint64 `json:"memoryBytes,omitempty"` + AvailableBytes uint64 `json:"availableBytes,omitempty"` + MemoryAfterBytes uint64 `json:"memoryAfterBytes,omitempty"` + MemoryLimit uint64 `json:"memoryLimit,omitempty"` + TriggerBytes uint64 `json:"triggerBytes,omitempty"` + ArmedBytes uint64 `json:"armedBytes,omitempty"` + ResumeBytes uint64 `json:"resumeBytes,omitempty"` + ReportOnly bool `json:"reportOnly,omitempty"` + Connections int `json:"connections,omitempty"` + Sequence int `json:"seq,omitempty"` + Prefix string `json:"prefix,omitempty"` + Runtime *runtimeStats `json:"runtime,omitempty"` +} + +type runtimeStats struct { + HeapAlloc uint64 `json:"heapAlloc"` + HeapObjects uint64 `json:"heapObjects"` + HeapInuse uint64 `json:"heapInuse"` + HeapIdle uint64 `json:"heapIdle"` + HeapReleased uint64 `json:"heapReleased"` + HeapSys uint64 `json:"heapSys"` + StackInuse uint64 `json:"stackInuse"` + StackSys uint64 `json:"stackSys"` + Sys uint64 `json:"sys"` + TotalAlloc uint64 `json:"totalAlloc"` + NumGC uint32 `json:"numGC"` + NumGoroutine int `json:"numGoroutine"` + NextGC uint64 `json:"nextGC"` + LastGC string `json:"lastGC,omitempty"` +} + +func NewRecorder(options RecorderOptions) *Recorder { + recorderLogger := options.Logger + if recorderLogger == nil { + recorderLogger = logger.NOP() + } + return &Recorder{ + basePath: options.BasePath, + draftPath: filepath.Join(options.BasePath, DraftDirectoryName), + logger: recorderLogger, + acceptDraft: options.AcceptDraft, + metadataCallback: options.MetadataCallback, + ownerCallback: options.OwnerCallback, + logCallback: options.LogCallback, + snapshotCallback: options.SnapshotCallback, + metricsSamples: []metrics.Sample{ + {Name: "/memory/classes/total:bytes"}, + {Name: "/gc/heap/live:bytes"}, + {Name: "/memory/classes/heap/stacks:bytes"}, + {Name: "/sched/goroutines:goroutines"}, + {Name: "/gc/cycles/total:gc-cycles"}, + }, + lastSnapshotAt: make(map[string]time.Time), + } +} + +func (r *Recorder) Start() { + r.access.Lock() + defer r.access.Unlock() + if r.started { + return + } + PromoteDraft(r.basePath, r.acceptDraft) + r.started = true + r.status.StartedAt = time.Now() +} + +func (r *Recorder) Close() error { + r.snapshotAccess.Lock() + defer r.snapshotAccess.Unlock() + r.access.Lock() + defer r.access.Unlock() + if !r.started || r.closed { + return nil + } + r.closed = true + 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 +} + +func (r *Recorder) releaseDraftLocked() { + if r.draftLock == nil { + return + } + os.Remove(r.draftLock.Name()) + unlockDraft(r.draftLock) + r.draftLock = nil +} + +func (r *Recorder) WriteReport() error { + sample := memorySample{usage: memory.Total()} + if memory.AvailableAvailable() { + sample.availableKnown = true + sample.available = memory.Available() + } + err := r.snapshot(SnapshotReasonManual, sample, true) + if err != nil { + return E.Cause(err, "write snapshot") + } + r.access.Lock() + defer r.access.Unlock() + reportsDir := filepath.Join(r.basePath, ReportsDirectoryName) + err = os.MkdirAll(reportsDir, 0o777) + if err != nil { + return E.Cause(err, "create reports directory") + } + r.chown(reportsDir) + destPath, err := nextAvailableReportPath(reportsDir, time.Now().UTC()) + if err != nil { + return err + } + r.appendEventLocked(eventRecord{Type: eventTypeReport, MemoryBytes: sample.usage, AvailableBytes: sample.available}) + err = copyDirectory(r.draftPath, destPath) + if err != nil { + os.RemoveAll(destPath) + return E.Cause(err, "copy draft to ", destPath) + } + r.chownTree(destPath) + return nil +} + +func (r *Recorder) instanceStarted(config timerConfig, thresholds pressureThresholds) { + r.access.Lock() + defer r.access.Unlock() + r.status.MemoryLimit = config.memoryLimit + r.appendEventLocked(eventRecord{ + Type: eventTypeStart, + Policy: config.policyMode.String(), + MemoryLimit: config.memoryLimit, + TriggerBytes: thresholds.trigger, + ArmedBytes: thresholds.armed, + ResumeBytes: thresholds.resume, + ReportOnly: config.killerDisabled, + }) +} + +func (r *Recorder) instanceStopped() { + r.access.Lock() + defer r.access.Unlock() + r.appendEventLocked(eventRecord{Type: eventTypeStop}) +} + +func (r *Recorder) sample(sample memorySample, state pressureState, connections int) { + now := time.Now() + r.access.Lock() + defer r.access.Unlock() + if r.closed { + return + } + r.observeLocked(sample) + if r.hasRow && state == r.lastRowState { + interval := normalSampleInterval + if now.Before(r.denseUntil) { + interval = pressureSampleInterval + } + if now.Sub(r.lastRowAt) < interval { + return + } + } + err := r.ensureDraftLocked() + if err != nil { + return + } + metrics.Read(r.metricsSamples) + gcCycles := r.metricsSamples[4].Value.Uint64() + row := timelineRow{ + At: now.UTC().Format(time.RFC3339), + State: state.String(), + MemoryBytes: sample.usage, + AvailableBytes: sample.available, + GoMemoryBytes: r.metricsSamples[0].Value.Uint64(), + GoHeapLiveBytes: r.metricsSamples[1].Value.Uint64(), + GoStackBytes: r.metricsSamples[2].Value.Uint64(), + Goroutines: r.metricsSamples[3].Value.Uint64(), + Connections: connections, + } + if r.hasRow { + row.GCCycles = gcCycles - r.previousGCCycles + } + r.previousGCCycles = gcCycles + r.hasRow = true + r.lastRowAt = now + r.lastRowState = state + timelinePath := filepath.Join(r.draftPath, timelineFileName) + err = appendRecord(timelinePath, row) + if err != nil { + r.logger.Error(E.Cause(err, "OOM report: write timeline")) + return + } + r.chown(timelinePath) +} + +//nolint:unused +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}) +} + +func (r *Recorder) recordStateChange(state pressureState, sample memorySample) { + r.access.Lock() + defer r.access.Unlock() + r.observeLocked(sample) + r.denseUntil = time.Now().Add(denseSampleWindow) + r.appendEventLocked(eventRecord{Type: eventTypeState, State: state.String(), MemoryBytes: sample.usage, AvailableBytes: sample.available}) +} + +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{ + Type: eventTypeReset, + Reason: reason, + MemoryBytes: before.usage, + AvailableBytes: before.available, + MemoryAfterBytes: after.usage, + Connections: connections, + ReportOnly: reportOnly, + }) +} + +func (r *Recorder) snapshot(reason string, sample memorySample, force bool) error { + r.snapshotAccess.Lock() + defer r.snapshotAccess.Unlock() + now := time.Now() + if !force { + if r.automaticSnapshots >= maxAutomaticSnapshots { + return nil + } + var minInterval time.Duration + switch reason { + case SnapshotReasonPressure: + minInterval = pressureSnapshotMinInterval + case SnapshotReasonReset: + minInterval = resetSnapshotMinInterval + } + lastAt, found := r.lastSnapshotAt[reason] + if found && now.Sub(lastAt) < minInterval { + return nil + } + } + r.access.Lock() + if r.closed { + r.access.Unlock() + return E.New("OOM recorder closed") + } + err := r.ensureDraftLocked() + r.access.Unlock() + if err != nil { + return err + } + r.snapshotSequence++ + sequence := r.snapshotSequence + if !force { + r.automaticSnapshots++ + } + r.lastSnapshotAt[reason] = now + prefix := "snapshot-" + strconv.Itoa(sequence) + "-" + reason + if r.snapshotCallback != nil { + r.snapshotCallback(r.draftPath, prefix) + } + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + stats := &runtimeStats{ + HeapAlloc: memStats.HeapAlloc, + HeapObjects: memStats.HeapObjects, + HeapInuse: memStats.HeapInuse, + HeapIdle: memStats.HeapIdle, + HeapReleased: memStats.HeapReleased, + HeapSys: memStats.HeapSys, + StackInuse: memStats.StackInuse, + StackSys: memStats.StackSys, + Sys: memStats.Sys, + TotalAlloc: memStats.TotalAlloc, + NumGC: memStats.NumGC, + NumGoroutine: runtime.NumGoroutine(), + NextGC: memStats.NextGC, + } + if memStats.LastGC > 0 { + stats.LastGC = time.Unix(0, int64(memStats.LastGC)).UTC().Format(time.RFC3339) + } + r.access.Lock() + defer r.access.Unlock() + r.notable = true + r.status.Snapshots = sequence + r.observeLocked(sample) + r.writeLogLocked() + r.appendEventLocked(eventRecord{ + Type: eventTypeSnapshot, + Reason: reason, + Sequence: sequence, + Prefix: prefix, + MemoryBytes: sample.usage, + AvailableBytes: sample.available, + Runtime: stats, + }) + return nil +} + +func (r *Recorder) observeLocked(sample memorySample) { + if sample.usage > r.status.PeakMemory { + r.status.PeakMemory = sample.usage + } + if sample.availableKnown && (!r.status.AvailableKnown || sample.available < r.status.MinAvailable) { + r.status.AvailableKnown = true + r.status.MinAvailable = sample.available + } +} + +func (r *Recorder) ensureDraftLocked() error { + if r.draftCreated { + _, err := os.Stat(r.draftPath) + if err == nil { + return nil + } + r.logger.Error("OOM report: draft directory lost, recreating") + r.releaseDraftLocked() + r.draftCreated = false + r.hasRow = false + } + if !r.started { + return E.New("OOM recorder not started") + } + if r.closed { + return E.New("OOM recorder closed") + } + err := os.MkdirAll(r.draftPath, 0o777) + if err != nil { + r.logger.Error(E.Cause(err, "OOM report: create draft directory")) + return E.Cause(err, "create draft directory ", r.draftPath) + } + r.chown(r.draftPath) + lockPath := filepath.Join(r.draftPath, lockFileName) + r.draftLock, err = lockDraft(lockPath) + if err != nil { + return E.Cause(err, "lock draft directory ", r.draftPath) + } + r.chown(lockPath) + r.draftCreated = true + r.writeMetadataLocked() + return nil +} + +func (r *Recorder) appendEventLocked(event eventRecord) { + if r.closed { + return + } + err := r.ensureDraftLocked() + if err != nil { + return + } + event.At = time.Now().UTC().Format(time.RFC3339) + eventsPath := filepath.Join(r.draftPath, eventsFileName) + err = appendRecord(eventsPath, event) + if err != nil { + r.logger.Error(E.Cause(err, "OOM report: write events")) + } else { + r.chown(eventsPath) + } + r.writeMetadataLocked() +} + +func (r *Recorder) writeMetadataLocked() { + if r.metadataCallback == nil { + return + } + r.status.RecordedAt = time.Now() + content, err := json.Marshal(r.metadataCallback(r.status)) + if err != nil { + return + } + r.writeFile(filepath.Join(r.draftPath, metadataFileName), content) +} + +func (r *Recorder) writeLogLocked() { + if r.logCallback == nil { + return + } + content := r.logCallback() + if len(content) == 0 { + return + } + r.writeFile(filepath.Join(r.draftPath, logFileName), content) +} + +func (r *Recorder) writeFile(path string, content []byte) { + err := os.WriteFile(path, content, 0o666) + if err != nil { + r.logger.Error(E.Cause(err, "OOM report: write ", filepath.Base(path))) + return + } + r.chown(path) +} + +func (r *Recorder) chown(path string) { + if r.ownerCallback != nil { + r.ownerCallback(path) + } +} + +func (r *Recorder) chownTree(directory string) { + r.chown(directory) + entries, err := os.ReadDir(directory) + if err != nil { + return + } + for _, entry := range entries { + r.chown(filepath.Join(directory, entry.Name())) + } +} + +func appendRecord(path string, record any) error { + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666) + if err != nil { + return err + } + defer file.Close() + return json.NewEncoder(file).Encode(record) +} diff --git a/service/oomkiller/service.go b/service/oomkiller/service.go index d2bef722..7ce51616 100644 --- a/service/oomkiller/service.go +++ b/service/oomkiller/service.go @@ -2,39 +2,29 @@ package oomkiller import ( "context" - "sync/atomic" - "time" "github.com/sagernet/sing-box/adapter" boxService "github.com/sagernet/sing-box/adapter/service" boxConstant "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/service" ) -type OOMReporter interface { - WriteReport(memoryUsage uint64) error - WriteDraft(memoryUsage uint64) error - DiscardDraft() error -} - func RegisterService(registry *boxService.Registry) { boxService.Register[option.OOMKillerServiceOptions](registry, boxConstant.TypeOOMKiller, NewService) } type Service struct { boxService.Adapter - ctx context.Context - logger log.ContextLogger - network adapter.NetworkManager - timerConfig timerConfig - adaptiveTimer *adaptiveTimer - lastReportTime atomic.Int64 - //nolint:unused // touched only on darwin && cgo via writeOOMDraft/discardOOMDraft. - lastDraftTime atomic.Int64 - //nolint:unused // touched only on darwin && cgo via writeOOMDraft/discardOOMDraft. - draftCancelled atomic.Bool + ctx context.Context + logger log.ContextLogger + network adapter.NetworkManager + connections adapter.ConnectionManager + recorder *Recorder + timerConfig timerConfig + adaptiveTimer *adaptiveTimer } func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.OOMKillerServiceOptions) (adapter.Service, error) { @@ -48,27 +38,29 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio ctx: ctx, logger: logger, network: service.FromContext[adapter.NetworkManager](ctx), + connections: service.FromContext[adapter.ConnectionManager](ctx), + recorder: service.FromContext[*Recorder](ctx), timerConfig: config, }, nil } -func (s *Service) writeOOMReport(memoryUsage uint64) { - now := time.Now().Unix() - lastReport := s.lastReportTime.Load() - if now-lastReport < 3600 { - return +func (s *Service) startTimer() error { + if !s.timerConfig.policyMode.hasTimerMode() { + return E.New("memory pressure monitoring is not available on this platform without memory_limit") } - if !s.lastReportTime.CompareAndSwap(lastReport, now) { - return + s.adaptiveTimer = newAdaptiveTimer(s.logger, s.network, s.connections, s.recorder, s.timerConfig) + if s.recorder != nil { + s.recorder.instanceStarted(s.timerConfig, s.adaptiveTimer.limitThresholds) } - reporter := service.FromContext[OOMReporter](s.ctx) - if reporter == nil { - return + s.adaptiveTimer.start() + return nil +} + +func (s *Service) stopTimer() { + if s.adaptiveTimer != nil { + s.adaptiveTimer.stop() } - err := reporter.WriteReport(memoryUsage) - if err != nil { - s.logger.Warn("failed to write OOM report: ", err) - } else { - s.logger.Info("OOM report saved") + if s.recorder != nil { + s.recorder.instanceStopped() } } diff --git a/service/oomkiller/service_darwin.go b/service/oomkiller/service_darwin.go index 166ddc7d..a3814cde 100644 --- a/service/oomkiller/service_darwin.go +++ b/service/oomkiller/service_darwin.go @@ -34,16 +34,11 @@ import "C" import ( "sync" - "time" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing/common/byteformats" - E "github.com/sagernet/sing/common/exceptions" - "github.com/sagernet/sing/service" ) -const oomDraftMinInterval = time.Hour - var ( globalAccess sync.Mutex globalServices []*Service @@ -53,8 +48,11 @@ func (s *Service) Start(stage adapter.StartStage) error { if stage != adapter.StartStateStart { return nil } + err := s.startTimer() + if err != nil { + return err + } if s.timerConfig.policyMode == policyModeNetworkExtension { - s.adaptiveTimer = newAdaptiveTimer(s.logger, s.network, s.timerConfig, nil) globalAccess.Lock() isFirst := len(globalServices) == 0 globalServices = append(globalServices, s) @@ -62,20 +60,12 @@ func (s *Service) Start(stage adapter.StartStage) error { if isFirst { C.startMemoryPressureMonitor() } - return nil } - if !s.timerConfig.policyMode.hasTimerMode() { - return E.New("memory pressure monitoring is not available on this platform without memory_limit") - } - s.adaptiveTimer = newAdaptiveTimer(s.logger, s.network, s.timerConfig, s.writeOOMReport) - s.adaptiveTimer.start() return nil } func (s *Service) Close() error { - if s.adaptiveTimer != nil { - s.adaptiveTimer.stop() - } + s.stopTimer() if s.timerConfig.policyMode == policyModeNetworkExtension { globalAccess.Lock() for i, svc := range globalServices { @@ -89,7 +79,6 @@ func (s *Service) Close() error { if isLast { C.stopMemoryPressureMonitor() } - s.discardOOMDraft() } return nil } @@ -106,45 +95,12 @@ func goMemoryPressureCallback(status C.ulong) { sample := readMemorySample(policyModeNetworkExtension) for _, s := range services { s.logger.Warn("memory pressure: critical, usage: ", byteformats.FormatMemoryBytes(sample.usage)) - s.writeOOMDraft(sample.usage) + if s.recorder != nil { + s.recorder.recordPressure(sample) + } s.adaptiveTimer.notifyPressure() - } -} - -func (s *Service) writeOOMDraft(memoryUsage uint64) { - if s.draftCancelled.Load() { - return - } - now := time.Now().UnixNano() - lastDraft := s.lastDraftTime.Load() - if time.Duration(now-lastDraft) < oomDraftMinInterval { - return - } - s.lastDraftTime.Store(now) - reporter := service.FromContext[OOMReporter](s.ctx) - if reporter == nil { - return - } - err := reporter.WriteDraft(memoryUsage) - if s.draftCancelled.Load() { - reporter.DiscardDraft() - return - } - if err != nil { - s.logger.Error("failed to write OOM draft: ", err) - } else { - s.logger.Warn("OOM draft saved") - } -} - -func (s *Service) discardOOMDraft() { - s.draftCancelled.Store(true) - reporter := service.FromContext[OOMReporter](s.ctx) - if reporter == nil { - return - } - err := reporter.DiscardDraft() - if err != nil { - s.logger.Error("failed to discard OOM draft: ", err) + if s.recorder != nil { + s.recorder.snapshot(SnapshotReasonPressure, sample, false) + } } } diff --git a/service/oomkiller/service_stub.go b/service/oomkiller/service_stub.go index 81ebbf03..f75fe8d5 100644 --- a/service/oomkiller/service_stub.go +++ b/service/oomkiller/service_stub.go @@ -4,24 +4,16 @@ package oomkiller import ( "github.com/sagernet/sing-box/adapter" - E "github.com/sagernet/sing/common/exceptions" ) func (s *Service) Start(stage adapter.StartStage) error { if stage != adapter.StartStateStart { return nil } - if !s.timerConfig.policyMode.hasTimerMode() { - return E.New("memory pressure monitoring is not available on this platform without memory_limit") - } - s.adaptiveTimer = newAdaptiveTimer(s.logger, s.network, s.timerConfig, s.writeOOMReport) - s.adaptiveTimer.start() - return nil + return s.startTimer() } func (s *Service) Close() error { - if s.adaptiveTimer != nil { - s.adaptiveTimer.stop() - } + s.stopTimer() return nil } diff --git a/service/oomkiller/timer.go b/service/oomkiller/timer.go index 1f38956f..5dfdd7b3 100644 --- a/service/oomkiller/timer.go +++ b/service/oomkiller/timer.go @@ -102,7 +102,8 @@ type adaptiveTimer struct { timerConfig logger log.ContextLogger network adapter.NetworkManager - onTriggered func(uint64) + connections adapter.ConnectionManager + recorder *Recorder limitThresholds pressureThresholds access sync.Mutex @@ -115,12 +116,13 @@ type adaptiveTimer struct { pressureBaselineTime time.Time } -func newAdaptiveTimer(logger log.ContextLogger, network adapter.NetworkManager, config timerConfig, onTriggered func(uint64)) *adaptiveTimer { +func newAdaptiveTimer(logger log.ContextLogger, network adapter.NetworkManager, connections adapter.ConnectionManager, recorder *Recorder, config timerConfig) *adaptiveTimer { t := &adaptiveTimer{ timerConfig: config, logger: logger, network: network, - onTriggered: onTriggered, + connections: connections, + recorder: recorder, } if config.policyMode == policyModeMemoryLimit || config.policyMode == policyModeNetworkExtension { t.limitThresholds = computeLimitThresholds(config.memoryLimit, config.safetyMargin) @@ -192,14 +194,24 @@ func (t *adaptiveTimer) poll() { } } } + state := t.state t.access.Unlock() + var connections int + if t.connections != nil { + connections = t.connections.Count() + } + if t.recorder != nil { + t.recorder.sample(sample, state, connections) + if state != previousState { + t.recorder.recordStateChange(state, sample) + } + } if !triggered { return } - if t.onTriggered != nil { - t.onTriggered(sample.usage) - } + var reason string if rateTriggered { + reason = resetReasonRate if t.killerDisabled { t.logger.Warn("memory growth rate critical (report only), usage: ", byteformats.FormatMemoryBytes(sample.usage), t.logDetails(sample)) } else { @@ -207,6 +219,7 @@ func (t *adaptiveTimer) poll() { t.network.ResetNetwork(context.Background()) } } else { + reason = resetReasonThreshold if t.killerDisabled { t.logger.Warn("memory threshold reached (report only), usage: ", byteformats.FormatMemoryBytes(sample.usage), t.logDetails(sample)) } else { @@ -216,6 +229,10 @@ 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) + } } func (t *adaptiveTimer) nextState(sample memorySample) pressureState { @@ -320,9 +337,20 @@ func readMemorySample(mode policyMode) memorySample { sample := memorySample{ usage: memory.Total(), } - if mode == policyModeAvailable { + if mode == policyModeAvailable || mode == policyModeNetworkExtension { sample.availableKnown = true sample.available = memory.Available() } return sample } + +func (s pressureState) String() string { + switch s { + case pressureStateArmed: + return "armed" + case pressureStateTriggered: + return "triggered" + default: + return "normal" + } +} diff --git a/service/oomkiller/timer_darwin.go b/service/oomkiller/timer_darwin.go index f73ab28f..7cac34bc 100644 --- a/service/oomkiller/timer_darwin.go +++ b/service/oomkiller/timer_darwin.go @@ -5,9 +5,13 @@ package oomkiller import runtimeDebug "runtime/debug" func (t *adaptiveTimer) notifyPressure() { + badCleanup() runtimeDebug.FreeOSMemory() t.access.Lock() - t.startLocked() + if t.timer == nil { + t.access.Unlock() + return + } t.forceMinInterval = true t.pendingPressureBaseline = true t.access.Unlock() diff --git a/service/powerreport/record.go b/service/powerreport/record.go index 730f97c7..d0e3220c 100644 --- a/service/powerreport/record.go +++ b/service/powerreport/record.go @@ -65,6 +65,7 @@ type timelineRow struct { ConnectionsOpened uint64 `json:"connectionsOpened,omitempty"` InterfacePackets map[string]uint64 `json:"interfacePackets,omitempty"` NetworkType string `json:"network,omitempty"` + NetworkPathUpdates uint64 `json:"pathUpdates,omitempty"` } type qosBreakdown struct { @@ -80,6 +81,7 @@ type qosBreakdown struct { const ( eventTypeBreak = "break" eventTypeNetwork = "network" + eventTypePath = "path" ) type eventRecord struct { @@ -89,6 +91,7 @@ type eventRecord struct { 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"` } diff --git a/service/powerreport/recorder.go b/service/powerreport/recorder.go index 26fe4a32..0b903a8d 100644 --- a/service/powerreport/recorder.go +++ b/service/powerreport/recorder.go @@ -70,11 +70,13 @@ type Recorder struct { pendingBreak atomic.Pointer[breakRecord] notify chan struct{} - dnsQueries atomic.Uint64 - connectionsOpened atomic.Uint64 + dnsQueries atomic.Uint64 + connectionsOpened atomic.Uint64 + networkPathUpdates atomic.Uint64 access sync.Mutex networkType string + networkPath string rows []timelineRow events []eventRecord previous previousSample @@ -96,15 +98,16 @@ type breakRecord struct { } type previousSample struct { - at time.Time - usage systemUsage - gcSeconds float64 - gcCycles uint64 - absoluteTime int64 - continuousTime int64 - interfaces map[string]interfaceCounters - dnsQueries uint64 - connectionsOpened uint64 + at time.Time + usage systemUsage + gcSeconds float64 + gcCycles uint64 + absoluteTime int64 + continuousTime int64 + interfaces map[string]interfaceCounters + dnsQueries uint64 + connectionsOpened uint64 + networkPathUpdates uint64 } func NewRecorder(options Options) *Recorder { @@ -285,6 +288,24 @@ func (r *Recorder) UpdateNetworkType(networkType string) { r.notifyWorker() } +func (r *Recorder) UpdateNetworkPath(description string) { + r.networkPathUpdates.Add(1) + now := time.Now() + r.access.Lock() + if r.closed || r.networkPath == description { + r.access.Unlock() + return + } + r.networkPath = description + r.events = append(r.events, eventRecord{ + Type: eventTypePath, + At: now.UTC().Format(time.RFC3339), + NetworkPath: description, + }) + r.access.Unlock() + r.notifyWorker() +} + func (r *Recorder) notifyWorker() { select { case r.notify <- struct{}{}: @@ -345,13 +366,14 @@ func (r *Recorder) consumeBreakLocked() { func (r *Recorder) resetPreviousLocked(now time.Time) { metrics.Read(r.metricsSamples) r.previous = previousSample{ - at: now, - usage: readSystemUsage(), - gcSeconds: r.metricsSamples[0].Value.Float64(), - gcCycles: r.metricsSamples[2].Value.Uint64(), - interfaces: readInterfaceCounters(), - dnsQueries: r.dnsQueries.Load(), - connectionsOpened: r.connectionsOpened.Load(), + at: now, + usage: readSystemUsage(), + gcSeconds: r.metricsSamples[0].Value.Float64(), + gcCycles: r.metricsSamples[2].Value.Uint64(), + interfaces: readInterfaceCounters(), + dnsQueries: r.dnsQueries.Load(), + connectionsOpened: r.connectionsOpened.Load(), + networkPathUpdates: r.networkPathUpdates.Load(), } r.previous.absoluteTime, r.previous.continuousTime = readClocks() } @@ -361,16 +383,17 @@ func (r *Recorder) sampleLocked(now time.Time) { r.resetPreviousLocked(now) current := &r.previous row := timelineRow{ - From: previous.at.UTC().Format(time.RFC3339), - To: now.UTC().Format(time.RFC3339), - CPUGCMS: int64((current.gcSeconds - previous.gcSeconds) * 1000), - Goroutines: r.metricsSamples[1].Value.Uint64(), - GCCycles: current.gcCycles - previous.gcCycles, - GoMemoryBytes: r.metricsSamples[3].Value.Uint64(), - GoHeapLiveBytes: r.metricsSamples[4].Value.Uint64(), - DNSQueries: current.dnsQueries - previous.dnsQueries, - ConnectionsOpened: current.connectionsOpened - previous.connectionsOpened, - NetworkType: r.networkType, + From: previous.at.UTC().Format(time.RFC3339), + To: now.UTC().Format(time.RFC3339), + CPUGCMS: int64((current.gcSeconds - previous.gcSeconds) * 1000), + Goroutines: r.metricsSamples[1].Value.Uint64(), + GCCycles: current.gcCycles - previous.gcCycles, + GoMemoryBytes: r.metricsSamples[3].Value.Uint64(), + GoHeapLiveBytes: r.metricsSamples[4].Value.Uint64(), + DNSQueries: current.dnsQueries - previous.dnsQueries, + ConnectionsOpened: current.connectionsOpened - previous.connectionsOpened, + NetworkType: r.networkType, + NetworkPathUpdates: current.networkPathUpdates - previous.networkPathUpdates, } if memory.TotalAvailable() { row.MemoryBytes = memory.Total() diff --git a/service/resolved/resolve1.go b/service/resolved/resolve1.go index 6b347060..dadc975b 100644 --- a/service/resolved/resolve1.go +++ b/service/resolved/resolve1.go @@ -119,9 +119,9 @@ func (t *resolve1Manager) createMetadata(sender dbus.Sender) adapter.InboundCont if err == nil { processInfo.ProcessPath = processPath } else { - processPath, err = os.Readlink(F.ToString("/proc/", senderPid, "/comm")) - if err == nil { - processInfo.ProcessPath = processPath + processName, readErr := os.ReadFile(F.ToString("/proc/", senderPid, "/comm")) + if readErr == nil { + processInfo.ProcessPath = strings.TrimSpace(string(processName)) } } diff --git a/test/openvpn_test.go b/test/openvpn_test.go index 80f27e58..950db0cb 100644 --- a/test/openvpn_test.go +++ b/test/openvpn_test.go @@ -197,7 +197,7 @@ func TestOpenVPNStaticKeyClientDataPath(t *testing.T) { Mode: ovpn.ModeStaticKey, Network: N.NetworkTCP, Address: []netip.Prefix{netip.MustParsePrefix(clientTunnelAddress + "/30")}, - PeerAddress: badoption.Addr(netip.MustParseAddr(peerTunnelAddress)), + PeerAddress: common.Ptr(badoption.Addr(netip.MustParseAddr(peerTunnelAddress))), Topology: "p2p", StaticKeyPath: staticKeyPath, KeyDirection: "client", @@ -286,7 +286,7 @@ func runOpenVPNStaticKeySelfToSelf(t *testing.T, protocol string) { RemotePort: clientOpenVPNPort, MaxClients: 1, Address: []netip.Prefix{netip.MustParsePrefix(serverTunnelAddress + "/30")}, - PeerAddress: badoption.Addr(netip.MustParseAddr(clientTunnelAddress)), + PeerAddress: common.Ptr(badoption.Addr(netip.MustParseAddr(clientTunnelAddress))), Topology: "p2p", StaticKeyPath: staticKeyPath, KeyDirection: "server", @@ -302,7 +302,7 @@ func runOpenVPNStaticKeySelfToSelf(t *testing.T, protocol string) { Mode: ovpn.ModeStaticKey, Network: protocol, Address: []netip.Prefix{netip.MustParsePrefix(clientTunnelAddress + "/30")}, - PeerAddress: badoption.Addr(netip.MustParseAddr(serverTunnelAddress)), + PeerAddress: common.Ptr(badoption.Addr(netip.MustParseAddr(serverTunnelAddress))), Topology: "p2p", StaticKeyPath: staticKeyPath, KeyDirection: "client", diff --git a/transport/wireguard/endpoint.go b/transport/wireguard/endpoint.go index e3f52c85..a2108279 100644 --- a/transport/wireguard/endpoint.go +++ b/transport/wireguard/endpoint.go @@ -333,9 +333,9 @@ func (e *Endpoint) BindUpdate() error { func (e *Endpoint) onPauseUpdated(event int) { switch event { - case pause.EventDevicePaused, pause.EventNetworkPause: + case pause.EventNetworkPause: e.device.Down() - case pause.EventDeviceWake, pause.EventNetworkWake: + case pause.EventNetworkWake: e.device.Up() } }